From eb08e2861e946308c7fdfce704961f245491cd61 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 2 Jun 2026 14:39:21 +0800 Subject: [PATCH 1/6] feat: enforce debug/host guard and document API versioning --- CHANGELOG.md | 22 +++++++++++ CONTRIBUTING.md | 13 ++++++- README.md | 1 + app.py | 30 ++++++++++++++ docs/api-reference.md | 80 +++++++++++++++++++++++--------------- docs/deprecation-policy.md | 36 +++++++++++++++++ tests/test_cli_args.py | 27 ++++++++++++- 7 files changed, 175 insertions(+), 34 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/deprecation-policy.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7a35894 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# 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/). + +## [0.1.0] - 2026-06-02 + +### Added + +- `__version__` in `app.py` for release tracking +- Startup guard refusing `--debug` with a non-loopback `--host` +- [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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fbf67ec..fce9650 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 at startup) + +## 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..3f121ca 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ 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`). ### CLI Export diff --git a/app.py b/app.py index 634442b..be21a53 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" + import argparse import os import sys @@ -13,6 +15,33 @@ from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules +def is_loopback_host(host: str) -> bool: + """True if ``host`` binds only to the local machine (safe with ``--debug``).""" + h = (host or "").strip().lower() + if h in ("127.0.0.1", "localhost", "::1"): + return True + if h.startswith("127.") and h.count(".") == 3: + parts = h.split(".") + try: + return len(parts) == 4 and all(0 <= int(p) <= 255 for p in parts) + except ValueError: + return False + return False + + +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, 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, + ) + raise SystemExit(1) + + def create_app( base_dir: str | None = None, exclusion_rules_path: str | None = None, @@ -60,6 +89,7 @@ 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}") diff --git a/docs/api-reference.md b/docs/api-reference.md index b14d8ed..f387950 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) +**Field stability:** [`deprecation-policy.md`](deprecation-policy.md) + +--- + +## 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 @@ -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..aa3c2c2 --- /dev/null +++ b/docs/deprecation-policy.md @@ -0,0 +1,36 @@ +# 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. +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. + +## 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). This project is pre-1.0; minor releases may add features; patch releases are fixes and documentation. diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 3a0da67..3b7f8bb 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, is_loopback_host, validate_startup_cli from scripts.export import build_parser @@ -321,6 +321,31 @@ 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", "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"]) + def test_is_loopback_host_rejects_non_loopback(self, host: str) -> None: + assert not is_loopback_host(host) + + def test_validate_startup_cli_allows_loopback_debug(self) -> None: + parser = build_cli_parser() + args = parser.parse_args(["--host", "127.0.0.1", "--debug"]) + validate_startup_cli(args) + + def test_validate_startup_cli_rejects_non_loopback_debug(self) -> 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 + + 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") From a5b301550a077b8713b6e4713debbf20d1349ed8 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 2 Jun 2026 15:32:35 +0800 Subject: [PATCH 2/6] feat: enforce debug/host guard and document API versioning --- app.py | 14 +++++++++++++- docs/api-reference.md | 30 +++++++++++++++--------------- tests/test_cli_args.py | 20 ++++++++++++++++++-- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/app.py b/app.py index be21a53..def993f 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,18 @@ def is_loopback_host(host: str) -> bool: 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 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): @@ -92,7 +104,7 @@ def build_cli_parser() -> argparse.ArgumentParser: 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 f387950..ba5f811 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -244,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 diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 3b7f8bb..21e4eeb 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, is_loopback_host, validate_startup_cli +from app import build_cli_parser, format_listen_url, is_loopback_host, validate_startup_cli from scripts.export import build_parser @@ -334,12 +334,28 @@ def test_validate_startup_cli_allows_loopback_debug(self) -> None: args = parser.parse_args(["--host", "127.0.0.1", "--debug"]) validate_startup_cli(args) - def test_validate_startup_cli_rejects_non_loopback_debug(self) -> None: + 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_validate_startup_cli_allows_non_loopback_without_debug(self) -> None: parser = build_cli_parser() From d7f47012888fcbe36af4dbb2c0324551d167a627 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 2 Jun 2026 15:49:50 +0800 Subject: [PATCH 3/6] fix: address PR review on debug guard and API docs - Simplify 127.x.x.x loopback check; reject empty host in format_listen_url - Add malformed 127.* tests; search endpoint stability column - CHANGELOG [0.1.0] footer link; SPA two-release deprecation note - README inline warning on 0.0.0.0 example --- CHANGELOG.md | 2 ++ README.md | 2 +- app.py | 6 ++++-- docs/api-reference.md | 16 ++++++++-------- docs/deprecation-policy.md | 2 ++ tests/test_cli_args.py | 17 ++++++++++++++++- 6 files changed, 33 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a35894..8778a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,3 +20,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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). + +[0.1.0]: https://github.com/cppalliance/claude-code-chat-browser/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 3f121ca..1be2f8c 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 ``` diff --git a/app.py b/app.py index def993f..e2f7cbc 100644 --- a/app.py +++ b/app.py @@ -23,7 +23,7 @@ def is_loopback_host(host: str) -> bool: if h.startswith("127.") and h.count(".") == 3: parts = h.split(".") try: - return len(parts) == 4 and all(0 <= int(p) <= 255 for p in parts) + return all(0 <= int(p) <= 255 for p in parts) except ValueError: return False return False @@ -32,6 +32,8 @@ def is_loopback_host(host: str) -> bool: 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: @@ -51,7 +53,7 @@ def validate_startup_cli(args: argparse.Namespace) -> None: "Werkzeug debugger and session data to other machines.", file=sys.stderr, ) - raise SystemExit(1) + sys.exit(1) def create_app( diff --git a/docs/api-reference.md b/docs/api-reference.md index ba5f811..c0ce690 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -292,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 diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md index aa3c2c2..843c193 100644 --- a/docs/deprecation-policy.md +++ b/docs/deprecation-policy.md @@ -25,6 +25,8 @@ A deprecated field may be removed when: - 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), the deprecation period will span **at least two releases** so the SPA and policy can be updated in the same release cycle as the final removal. + ## Example (in progress) | Field | Endpoint | Status | Replacement | diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index 21e4eeb..eaa3595 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -325,7 +325,18 @@ def test_debug_explicit_true(self): 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"]) + @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) @@ -357,6 +368,10 @@ def test_validate_startup_cli_rejects_non_loopback_debug( 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"]) From ab1bb2ebf81a3dfda2043cc66ad95523aaf88c03 Mon Sep 17 00:00:00 2001 From: chen Date: Tue, 2 Jun 2026 16:07:56 +0800 Subject: [PATCH 4/6] docs: address PR review on debug guard scope and deprecation wording --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 1 + app.py | 6 +++++- docs/deprecation-policy.md | 2 +- tests/test_cli_args.py | 5 +++-- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8778a95..1d713df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,4 +21,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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). -[0.1.0]: https://github.com/cppalliance/claude-code-chat-browser/releases/tag/v0.1.0 +[0.1.0]: https://github.com/cppalliance/claude-code-chat-browser/releases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fce9650..04d3053 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ 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; never with `--debug`) -- `--debug` — Flask/Werkzeug debug mode (loopback hosts only; enforced at startup) +- `--debug` — Flask/Werkzeug debug mode (loopback hosts only; enforced when starting via `python app.py`, not `flask run` or WSGI) ## API and release policy diff --git a/README.md b/README.md index 1be2f8c..f8c149c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ python app.py --base-dir /path/to/claude/projects > 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 e2f7cbc..430084a 100644 --- a/app.py +++ b/app.py @@ -16,7 +16,11 @@ def is_loopback_host(host: str) -> bool: - """True if ``host`` binds only to the local machine (safe with ``--debug``).""" + """True if ``host`` binds only to the local machine (safe with ``--debug``). + + Accepts ``127.0.0.1``, ``localhost``, ``::1``, and other ``127.x.x.x`` addresses. + Rejects all-interfaces forms such as ``0.0.0.0`` and bare ``::`` (not loopback). + """ h = (host or "").strip().lower() if h in ("127.0.0.1", "localhost", "::1"): return True diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md index 843c193..fbf9ae9 100644 --- a/docs/deprecation-policy.md +++ b/docs/deprecation-policy.md @@ -6,7 +6,7 @@ This document defines how **claude-code-chat-browser** evolves its HTTP JSON API 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. +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 diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py index eaa3595..7637fa3 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -340,9 +340,10 @@ def test_is_loopback_host_accepts_loopback(self, host: str) -> None: def test_is_loopback_host_rejects_non_loopback(self, host: str) -> None: assert not is_loopback_host(host) - def test_validate_startup_cli_allows_loopback_debug(self) -> None: + @pytest.mark.parametrize("host", ["127.0.0.1", "localhost"]) + def test_validate_startup_cli_allows_loopback_debug(self, host: str) -> None: parser = build_cli_parser() - args = parser.parse_args(["--host", "127.0.0.1", "--debug"]) + args = parser.parse_args(["--host", host, "--debug"]) validate_startup_cli(args) def test_validate_startup_cli_rejects_non_loopback_debug( From 1f5456ed19daf9fbefb0fbb9682bc110c56eed8f Mon Sep 17 00:00:00 2001 From: yu-med Date: Wed, 3 Jun 2026 01:26:22 +0800 Subject: [PATCH 5/6] fix: address PR review on loopback host, versioning, and docs --- CHANGELOG.md | 8 +++----- CONTRIBUTING.md | 2 +- app.py | 16 ++++++++++++---- docs/api-reference.md | 2 +- docs/deprecation-policy.md | 12 ++++++++++-- tests/test_cli_args.py | 6 ++++-- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d713df..1b4078e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ 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/). -## [0.1.0] - 2026-06-02 +## [Unreleased] ### Added -- `__version__` in `app.py` for release tracking -- Startup guard refusing `--debug` with a non-loopback `--host` +- `__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) @@ -20,5 +20,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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). - -[0.1.0]: https://github.com/cppalliance/claude-code-chat-browser/releases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04d3053..4be1f81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ 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; never with `--debug`) -- `--debug` — Flask/Werkzeug debug mode (loopback hosts only; enforced when starting via `python app.py`, not `flask run` or WSGI) +- `--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 diff --git a/app.py b/app.py index 430084a..3f8df3c 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ """Flask app that serves the web GUI for browsing sessions.""" -__version__ = "0.1.0" +__version__ = "0.1.0.dev0" import argparse import os @@ -15,13 +15,21 @@ 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``, and other ``127.x.x.x`` addresses. + 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 = (host or "").strip().lower() + h = _normalize_bind_host(host) if h in ("127.0.0.1", "localhost", "::1"): return True if h.startswith("127.") and h.count(".") == 3: @@ -52,7 +60,7 @@ def validate_startup_cli(args: argparse.Namespace) -> None: 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, or 127.x.x.x). " + "(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, diff --git a/docs/api-reference.md b/docs/api-reference.md index c0ce690..6cbd4f2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,7 +6,7 @@ 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) -**Field stability:** [`deprecation-policy.md`](deprecation-policy.md) +**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. --- diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md index fbf9ae9..a0056c8 100644 --- a/docs/deprecation-policy.md +++ b/docs/deprecation-policy.md @@ -25,7 +25,7 @@ A deprecated field may be removed when: - 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), the deprecation period will span **at least two releases** so the SPA and policy can be updated in the same release cycle as the final 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) @@ -35,4 +35,12 @@ For fields actively read by the bundled SPA (which does not track an external AP ## Versioning -Release versions follow `MAJOR.MINOR.PATCH` in `app.__version__` and [CHANGELOG](../CHANGELOG.md). This project is pre-1.0; minor releases may add features; patch releases are fixes and documentation. +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 7637fa3..9350af2 100644 --- a/tests/test_cli_args.py +++ b/tests/test_cli_args.py @@ -321,7 +321,9 @@ 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", "127.0.0.2"]) + @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) @@ -340,7 +342,7 @@ def test_is_loopback_host_accepts_loopback(self, host: str) -> None: 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"]) + @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"]) From df73fc51999259c2189d9b01ae212b7e20d055fc Mon Sep 17 00:00:00 2001 From: yu-med Date: Wed, 3 Jun 2026 01:34:12 +0800 Subject: [PATCH 6/6] docs: add Keep a Changelog [Unreleased] compare link --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b4078e..b977caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,3 +20,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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