diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b977caf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- `__version__` in `app.py` for release tracking (`0.1.0.dev0` until the first `v0.1.0` git tag) +- Startup guard refusing `--debug` with a non-loopback `--host` (including bracketed IPv6 loopback such as `[::1]`) +- [Deprecation policy](docs/deprecation-policy.md) for API and JSON field changes +- API field **stability** tables in `docs/api-reference.md` (stable / experimental / deprecated) + +### Changed + +- README notes that the server enforces the debug + host safety rule at startup + +### Deprecated + +- `export_count` on `GET /api/export/state` (documented only; still returned). Use `last_export_session_count`. Removal planned in a follow-up release per [deprecation policy](docs/deprecation-policy.md). + +[Unreleased]: https://github.com/cppalliance/claude-code-chat-browser/compare/f70505982d435f8b1f754cb18c0c9f65609f11b4...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbf67ec..4be1f81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,16 @@ Useful flags: - `--base-dir PATH` — point at a different `projects/` tree (for tests or fixtures) - `--exclude-rules PATH` — session exclusion rules file -- `--host 0.0.0.0` — listen on all interfaces (use only on trusted networks) +- `--host 0.0.0.0` — listen on all interfaces (use only on trusted networks; never with `--debug`) +- `--debug` — Flask/Werkzeug debug mode (loopback hosts only; enforced when starting via `python app.py`, not `flask run` or WSGI). Extending the guard to `FLASK_DEBUG` / `flask run` is a planned follow-up. + +## API and release policy + +- [CHANGELOG.md](CHANGELOG.md) — user-visible changes per release +- [docs/deprecation-policy.md](docs/deprecation-policy.md) — how deprecated API fields are removed +- [docs/api-reference.md](docs/api-reference.md) — field **stability** (`stable` / `experimental` / `deprecated`) + +When changing JSON response shapes, update the API reference stability column and CHANGELOG before removing fields. ## Running tests @@ -116,6 +125,8 @@ npm run test:coverage # optional | SPA shell + routing | [`static/index.html`](static/index.html), [`static/js/app.js`](static/js/app.js) | | Shared frontend utilities | [`static/js/shared/`](static/js/shared/) | | API documentation | [`docs/api-reference.md`](docs/api-reference.md) | +| Deprecation policy | [`docs/deprecation-policy.md`](docs/deprecation-policy.md) | +| Changelog | [`CHANGELOG.md`](CHANGELOG.md) | ## Architecture diff --git a/README.md b/README.md index 63881ec..f8c149c 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ python app.py Options: ```bash -python app.py --port 8080 --host 0.0.0.0 +python app.py --port 8080 --host 0.0.0.0 # never add --debug on 0.0.0.0 python app.py --base-dir /path/to/claude/projects ``` @@ -66,6 +66,8 @@ python app.py --base-dir /path/to/claude/projects > That combination exposes [Werkzeug's interactive debugger](https://werkzeug.palletsprojects.com/en/stable/debug/), > which allows arbitrary code execution from any browser that can reach the server. > For typical local browsing, keep the default `--host 127.0.0.1` and omit `--debug`. +> The server **refuses to start** if `--debug` is combined with a non-loopback `--host` (e.g. `0.0.0.0`). +> That check runs only when you start the app with **`python app.py`** (not via `flask run` or other WSGI entrypoints). ### CLI Export diff --git a/app.py b/app.py index 634442b..3f8df3c 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,7 @@ """Flask app that serves the web GUI for browsing sessions.""" +__version__ = "0.1.0.dev0" + import argparse import os import sys @@ -13,6 +15,59 @@ from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules +def _normalize_bind_host(host: str) -> str: + """Lowercase host for checks; strip optional IPv6 brackets (e.g. ``[::1]`` → ``::1``).""" + h = (host or "").strip().lower() + if len(h) >= 2 and h.startswith("[") and h.endswith("]"): + return h[1:-1] + return h + + +def is_loopback_host(host: str) -> bool: + """True if ``host`` binds only to the local machine (safe with ``--debug``). + + Accepts ``127.0.0.1``, ``localhost``, ``::1``, ``[::1]``, and other ``127.x.x.x`` addresses. + Rejects all-interfaces forms such as ``0.0.0.0`` and bare ``::`` (not loopback). + """ + h = _normalize_bind_host(host) + if h in ("127.0.0.1", "localhost", "::1"): + return True + if h.startswith("127.") and h.count(".") == 3: + parts = h.split(".") + try: + return all(0 <= int(p) <= 255 for p in parts) + except ValueError: + return False + return False + + +def format_listen_url(host: str, port: int) -> str: + """Return a valid ``http://`` URL for the startup banner (IPv6 hosts bracketed).""" + h = (host or "").strip() + if not h: + raise ValueError("host must not be empty") + if h.startswith("[") and h.endswith("]"): + display_host = h + elif ":" in h: + display_host = f"[{h}]" + else: + display_host = h + return f"http://{display_host}:{port}" + + +def validate_startup_cli(args: argparse.Namespace) -> None: + """Refuse ``--debug`` when ``--host`` is reachable off loopback.""" + if args.debug and not is_loopback_host(args.host): + print( + "error: --debug is only allowed with a loopback --host " + "(127.0.0.1, localhost, ::1, [::1], or 127.x.x.x). " + "Combining --debug with a network-visible --host exposes the " + "Werkzeug debugger and session data to other machines.", + file=sys.stderr, + ) + sys.exit(1) + + def create_app( base_dir: str | None = None, exclusion_rules_path: str | None = None, @@ -60,9 +115,10 @@ def build_cli_parser() -> argparse.ArgumentParser: if __name__ == "__main__": args = build_cli_parser().parse_args() + validate_startup_cli(args) app = create_app(base_dir=args.base_dir, exclusion_rules_path=args.exclude_rules) - print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}") + print(f"Claude Code Chat Browser running at {format_listen_url(args.host, args.port)}") # Reloader follows --debug on Unix only (Werkzeug file watcher, not the interactive debugger). app.run( host=args.host, diff --git a/docs/api-reference.md b/docs/api-reference.md index b14d8ed..6cbd4f2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,6 +6,22 @@ HTTP API for **claude-code-chat-browser**. All `/api/*` routes return JSON unles **Source of truth for error codes:** [`api/error_codes.py`](../api/error_codes.py) +**Deprecation and removal:** See [`deprecation-policy.md`](deprecation-policy.md) for how stability labels are applied and fields are removed. Field labels are defined in [API field stability](#api-field-stability) below. + +--- + +## API field stability + +Each response field below is labeled: + +| Label | Meaning | +|-------|---------| +| **stable** | Will not be renamed or removed without a documented deprecation period | +| **experimental** | May change in any release; do not build long-lived integrations on these fields | +| **deprecated** | Still returned; use the documented replacement; removal announced in [CHANGELOG](../CHANGELOG.md) | + +**Migration:** Breaking changes use additive deprecation first (new field → deprecate old → remove after policy period). Versioned routes (e.g. `/api/v2/...`) are reserved for future breaking reshapes; none exist today. + --- ## Authentication @@ -100,13 +116,13 @@ None. `application/json` — array of project objects: -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Directory name under `~/.claude/projects/` (e.g. `F--boost-capy`) | -| `path` | string | Absolute path to project directory | -| `display_name` | string | Friendly name derived from session `cwd` when available | -| `session_count` | integer | Count of titled sessions (updated in handler) | -| `last_modified` | string (ISO 8601) | Latest message timestamp across titled sessions | +| Field | Type | Stability | Description | +|-------|------|-----------|-------------| +| `name` | string | stable | Directory name under `~/.claude/projects/` (e.g. `F--boost-capy`) | +| `path` | string | stable | Absolute path to project directory | +| `display_name` | string | stable | Friendly name derived from session `cwd` when available | +| `session_count` | integer | stable | Count of titled sessions (updated in handler) | +| `last_modified` | string (ISO 8601) | stable | Latest message timestamp across titled sessions | ```json [ @@ -148,19 +164,19 @@ Lists sessions in one project with summary fields for the workspace sidebar. Ski `application/json` — array of session row objects: -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Session id (filename without `.jsonl`) | -| `path` | string | Absolute path to JSONL file | -| `size_bytes` | integer | File size | -| `modified` | number | File mtime (epoch seconds) | -| `title` | string | Parsed session title | -| `models` | string[] | Models used in session | -| `tokens` | integer | Sum of input + output tokens | -| `tool_calls` | integer | Total tool calls | -| `first_timestamp` | string \| null | First message timestamp | -| `last_timestamp` | string \| null | Last message timestamp | -| `error` | boolean | Optional; `true` if parse failed (card shows error state) | +| Field | Type | Stability | Description | +|-------|------|-----------|-------------| +| `id` | string | stable | Session id (filename without `.jsonl`) | +| `path` | string | stable | Absolute path to JSONL file | +| `size_bytes` | integer | stable | File size | +| `modified` | number | stable | File mtime (epoch seconds) | +| `title` | string | stable | Parsed session title | +| `models` | string[] | stable | Models used in session | +| `tokens` | integer | stable | Sum of input + output tokens | +| `tool_calls` | integer | stable | Total tool calls | +| `first_timestamp` | string \| null | stable | First message timestamp | +| `last_timestamp` | string \| null | stable | Last message timestamp | +| `error` | boolean | stable | Optional; `true` if parse failed (card shows error state) | #### Errors @@ -191,14 +207,14 @@ Returns the full parsed session: title, metadata, and messages (including tool c `application/json` — session object: -| Top-level field | Type | Description | -|-----------------|------|-------------| -| `session_id` | string | Session identifier | -| `title` | string | Inferred title from first human message | -| `messages` | array | Ordered message objects (`role`, `text`/`content`, tool fields, etc.) | -| `metadata` | object | Tokens, models, timestamps, file activity, tool counts, `cwd`, `git_branch`, … | +| Top-level field | Type | Stability | Description | +|-----------------|------|-----------|-------------| +| `session_id` | string | stable | Session identifier | +| `title` | string | stable | Inferred title from first human message | +| `messages` | array | stable | Ordered message objects (`role`, `text`/`content`, tool fields, etc.) | +| `metadata` | object | stable | Tokens, models, timestamps, file activity, tool counts, `cwd`, `git_branch`, … | -See [`utils/jsonl_parser.py`](../utils/jsonl_parser.py) `parse_session()` for the full metadata shape. +Nested keys inside `messages[]` and `metadata` follow the parser output; new parser fields may appear as **experimental** until listed here. See [`utils/jsonl_parser.py`](../utils/jsonl_parser.py) `parse_session()` for the full metadata shape. #### Errors @@ -228,21 +244,21 @@ Same as session detail. `application/json` — stats object from [`utils/session_stats.py`](../utils/session_stats.py) `compute_stats()`: -| Field | Type | Description | -|-------|------|-------------| -| `files_touched` | object | `read`, `written`, `created`, `total_unique` file lists | -| `commands_run` | array | Bash commands with exit metadata | -| `urls_accessed` | string[] | Web fetch URLs | -| `conversation_turns` | integer | Human/assistant turn count | -| `wall_clock_seconds` | number \| null | Session duration | -| `wall_clock_display` | string \| null | Human-readable duration | -| `cost_estimate_usd` | number | Best-effort USD estimate from token usage | -| `tool_result_summary` | object | Aggregated tool result stats | -| `stop_reason_summary` | object | Stop reason counts | -| `entry_type_counts` | object | JSONL entry type counts | -| `sidechain_message_count` | integer | Sidechain entries | -| `api_error_count` | integer | API errors in session | -| `compaction_events` | array | Context compaction markers | +| Field | Type | Stability | Description | +|-------|------|-----------|-------------| +| `files_touched` | object | stable | `read`, `written`, `created`, `total_unique` file lists | +| `commands_run` | array | stable | Bash commands with exit metadata | +| `urls_accessed` | string[] | stable | Web fetch URLs | +| `conversation_turns` | integer | stable | Human/assistant turn count | +| `wall_clock_seconds` | number \| null | stable | Session duration | +| `wall_clock_display` | string \| null | stable | Human-readable duration | +| `cost_estimate_usd` | number | stable | Best-effort USD estimate from token usage | +| `tool_result_summary` | object | stable | Aggregated tool result stats | +| `stop_reason_summary` | object | stable | Stop reason counts | +| `entry_type_counts` | object | stable | JSONL entry type counts | +| `sidechain_message_count` | integer | stable | Sidechain entries | +| `api_error_count` | integer | stable | API errors in session | +| `compaction_events` | array | stable | Context compaction markers | #### Errors @@ -276,14 +292,14 @@ Case-insensitive substring search across all non-excluded messages in all projec `application/json` — array of hit objects: -| Field | Type | Description | -|-------|------|-------------| -| `project` | string | Project `name` | -| `session_id` | string | Session id | -| `title` | string | Session title | -| `role` | string | Message role (`human`, `assistant`, …) | -| `timestamp` | string \| null | Message timestamp | -| `snippet` | string | ~160 chars around match | +| Field | Type | Stability | Description | +|-------|------|-----------|-------------| +| `project` | string | stable | Project `name` | +| `session_id` | string | stable | Session id | +| `title` | string | stable | Session title | +| `role` | string | stable | Message role (`human`, `assistant`, …) | +| `timestamp` | string \| null | stable | Message timestamp | +| `snippet` | string | experimental | ~160 chars around match; length may change | #### Errors @@ -306,11 +322,11 @@ Read-only snapshot of bulk-export state persisted under `~/.claude-code-chat-bro #### Response — `200 OK` -| Field | Type | Description | -|-------|------|-------------| -| `last_export_time` | string \| null | ISO timestamp of last completed bulk export | -| `last_export_session_count` | integer | Sessions in last bulk export run | -| `export_count` | integer | **Legacy alias** — same value as `last_export_session_count`; prefer `last_export_session_count` in new integrations (kept for SPA backwards compatibility) | +| Field | Type | Stability | Description | +|-------|------|-----------|-------------| +| `last_export_time` | string \| null | stable | ISO timestamp of last completed bulk export | +| `last_export_session_count` | integer | stable | Sessions in last bulk export run | +| `export_count` | integer | deprecated | Legacy alias of `last_export_session_count`; prefer `last_export_session_count` in new code (still returned for SPA compatibility; removal per [deprecation-policy.md](deprecation-policy.md)) | ```json { diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md new file mode 100644 index 0000000..a0056c8 --- /dev/null +++ b/docs/deprecation-policy.md @@ -0,0 +1,46 @@ +# Deprecation policy + +This document defines how **claude-code-chat-browser** evolves its HTTP JSON API and CLI without breaking integrators and the bundled SPA unexpectedly. + +## Principles + +1. **Documented fields are a contract.** See [API reference](api-reference.md) — each field is marked `stable`, `experimental`, or `deprecated`. +2. **Additive first.** Prefer adding a new field over renaming an existing one. +3. **Deprecate before removing.** A deprecated field remains in responses for at least **one release** after the deprecation is announced in [CHANGELOG](../CHANGELOG.md) and the API reference. Fields still read by the bundled SPA need **at least two releases** — see [Removal criteria](#removal-criteria) below. +4. **SPA and scripts.** Update `static/js/*.js` and any internal callers before removing a field. + +## How we announce deprecation + +| Channel | What to update | +|---------|----------------| +| CHANGELOG | `### Deprecated` under the release that announces the change | +| API reference | Set field stability to `deprecated` with a short note and replacement | +| Response (optional) | Future: `Deprecation` header or JSON `_deprecated` map — not required today | + +## Removal criteria + +A deprecated field may be removed when: + +- At least one release has shipped with the field still present but documented as deprecated, and +- The bundled SPA no longer reads the field, and +- Tests and CHANGELOG document the removal. + +For fields actively read by the bundled SPA (which does not track an external API version), removal happens no earlier than **two tagged releases** after the release that documented the deprecation in CHANGELOG (for example, deprecated in `0.1.0`, removable from `0.3.0` at earliest when versions advance `0.1.0` → `0.2.0` → `0.3.0`), and no earlier than **14 calendar days** after that deprecation announcement. + +## Example (in progress) + +| Field | Endpoint | Status | Replacement | +|-------|----------|--------|-------------| +| `export_count` | `GET /api/export/state` | deprecated | `last_export_session_count` | + +## Versioning + +Release versions follow `MAJOR.MINOR.PATCH` in `app.__version__` and [CHANGELOG](../CHANGELOG.md). Until the first git tag ships, `main` may carry a `.dev0` suffix (for example `0.1.0.dev0`); the CHANGELOG `[Unreleased]` section is the source of truth for what is not yet tagged. + +| Bump | Pre-1.0 meaning | +|------|-----------------| +| **Patch** | Bug fixes and documentation; no intentional API removals | +| **Minor** | Additive API/features; deprecations may be announced | +| **Major** | Reserved for the `1.0.0` line and later: signals a stable HTTP JSON contract for external integrators and may include breaking removals that completed their deprecation period | + +While the project is pre-1.0, treat **minor** bumps as the usual vehicle for deprecations and **patch** bumps for safe fixes. diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 3a0da67..9350af2 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -21,7 +21,7 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, REPO_ROOT) -from app import build_cli_parser +from app import build_cli_parser, format_listen_url, is_loopback_host, validate_startup_cli from scripts.export import build_parser @@ -321,6 +321,65 @@ def test_debug_explicit_true(self): args = parser.parse_args(["--debug"]) assert args.debug is True + @pytest.mark.parametrize( + "host", ["127.0.0.1", "localhost", "::1", "[::1]", "127.0.0.2"] + ) + def test_is_loopback_host_accepts_loopback(self, host: str) -> None: + assert is_loopback_host(host) + + @pytest.mark.parametrize( + "host", + [ + "0.0.0.0", + "192.168.1.1", + "", + "example.com", + "127.0.0.", + "127.256.0.0", + "127.-1.0.0", + ], + ) + def test_is_loopback_host_rejects_non_loopback(self, host: str) -> None: + assert not is_loopback_host(host) + + @pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "[::1]"]) + def test_validate_startup_cli_allows_loopback_debug(self, host: str) -> None: + parser = build_cli_parser() + args = parser.parse_args(["--host", host, "--debug"]) + validate_startup_cli(args) + + def test_validate_startup_cli_rejects_non_loopback_debug( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + parser = build_cli_parser() + args = parser.parse_args(["--host", "0.0.0.0", "--debug"]) + with pytest.raises(SystemExit) as exc_info: + validate_startup_cli(args) + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "debug" in err.lower() + assert "loopback" in err.lower() + + @pytest.mark.parametrize( + ("host", "port", "expected"), + [ + ("127.0.0.1", 5000, "http://127.0.0.1:5000"), + ("::1", 8080, "http://[::1]:8080"), + ("[::1]", 8080, "http://[::1]:8080"), + ], + ) + def test_format_listen_url(self, host: str, port: int, expected: str) -> None: + assert format_listen_url(host, port) == expected + + def test_format_listen_url_rejects_empty_host(self) -> None: + with pytest.raises(ValueError, match="host must not be empty"): + format_listen_url("", 5000) + + def test_validate_startup_cli_allows_non_loopback_without_debug(self) -> None: + parser = build_cli_parser() + args = parser.parse_args(["--host", "0.0.0.0"]) + validate_startup_cli(args) + def test_app_py_debug_not_hardcoded_true(self): """app.run() must wire debug from args, not a literal True.""" app_path = os.path.join(REPO_ROOT, "app.py")