diff --git a/claude_bottle/backend/docker/cred_proxy_apply.py b/claude_bottle/backend/docker/cred_proxy_apply.py deleted file mode 100644 index 94324b9..0000000 --- a/claude_bottle/backend/docker/cred_proxy_apply.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Host-side helper to apply a routes.json change to a running -cred-proxy sidecar (PRD 0014). - -Used by the supervise dashboard when the operator approves a -cred-proxy-block proposal (or runs the operator-initiated `routes -edit ` verb). Fetches the current routes.json via `docker -exec cat`, validates the new JSON, writes it into the sidecar via -`docker cp`, then `docker kill --signal HUP` to make the in-sidecar -SIGHUP handler (PRD 0014 Phase 1) reload without dropping -connections. - -Raises CredProxyApplyError on any failure — the dashboard surfaces -the message and keeps the proposal pending so the operator can -retry. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -from pathlib import Path - -# Constants inlined from the deleted `claude_bottle.backend.docker. -# cred_proxy` module (PRD 0017 chunk 2 cutover). Chunk 3 retargets -# this file at egress-proxy and gets rid of these. -CRED_PROXY_ROUTES_IN_CONTAINER = "/run/cred-proxy/routes.json" - - -def _cred_proxy_container_name(slug: str) -> str: - return f"claude-bottle-cred-proxy-{slug}" - - -class CredProxyApplyError(RuntimeError): - """Raised when fetch / apply fails. Caller renders to the - operator; does not crash the dashboard. - - PRD 0017 chunk 2 deletes the cred-proxy sidecar; this module's - docker-exec calls now hit a non-existent container and raise - CredProxyApplyError with a "container not running" message, - which the dashboard surfaces to the operator. Chunk 3 retargets - everything at egress-proxy.""" - - -def fetch_current_routes(slug: str) -> str: - """Read the live routes.json from the running cred-proxy sidecar - for `slug`. Returns the file content as a string. Raises - CredProxyApplyError if the sidecar isn't reachable or the read - fails.""" - container = _cred_proxy_container_name(slug) - r = subprocess.run( - ["docker", "exec", container, "cat", CRED_PROXY_ROUTES_IN_CONTAINER], - capture_output=True, text=True, check=False, - ) - if r.returncode != 0: - raise CredProxyApplyError( - f"could not read routes.json from {container}: " - f"{(r.stderr or '').strip() or 'container not running?'}" - ) - return r.stdout - - -def validate_routes_json(content: str) -> None: - """Syntactic check before SIGHUP — the sidecar's reload also - validates, but failing here keeps the old routes live and gives - the operator a clearer error than 'reload failed' in the - sidecar logs.""" - try: - parsed = json.loads(content) - except json.JSONDecodeError as e: - raise CredProxyApplyError( - f"proposed routes.json is not valid JSON: {e}" - ) from e - if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list): - raise CredProxyApplyError( - "proposed routes.json must be an object with a 'routes' array" - ) - - -def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: - """Apply `new_content` to the cred-proxy sidecar for `slug`: - 1. Fetch current routes.json (for the before-diff). - 2. Validate the new JSON. - 3. Write to a temp file, `docker cp` into the sidecar. - 4. `docker kill --signal HUP` so cred-proxy reloads. - - Returns (before, after) where `after` == `new_content`. Raises - CredProxyApplyError on any step; the existing routes in the - sidecar are unchanged if the failure is before docker cp, and - are reverted in spirit if SIGHUP fails (cp landed but reload - didn't fire — caller's next attempt will SIGHUP again).""" - container = _cred_proxy_container_name(slug) - before = fetch_current_routes(slug) - validate_routes_json(new_content) - - fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".json") - try: - with os.fdopen(fd, "w") as f: - f.write(new_content) - cp = subprocess.run( - ["docker", "cp", tmp_path, f"{container}:{CRED_PROXY_ROUTES_IN_CONTAINER}"], - capture_output=True, text=True, check=False, - ) - if cp.returncode != 0: - raise CredProxyApplyError( - f"failed to copy routes.json into {container}: " - f"{(cp.stderr or '').strip()}" - ) - sig = subprocess.run( - ["docker", "kill", "--signal", "HUP", container], - capture_output=True, text=True, check=False, - ) - if sig.returncode != 0: - raise CredProxyApplyError( - f"failed to SIGHUP {container}: " - f"{(sig.stderr or '').strip()}" - ) - finally: - try: - Path(tmp_path).unlink() - except OSError: - pass - - return before, new_content - - -__all__ = [ - "CredProxyApplyError", - "apply_routes_change", - "fetch_current_routes", - "validate_routes_json", -] diff --git a/claude_bottle/backend/docker/egress_proxy_apply.py b/claude_bottle/backend/docker/egress_proxy_apply.py new file mode 100644 index 0000000..6fe8b56 --- /dev/null +++ b/claude_bottle/backend/docker/egress_proxy_apply.py @@ -0,0 +1,116 @@ +"""Host-side helper to apply a routes.yaml change to a running +egress-proxy sidecar (PRD 0014 retargeted by PRD 0017 chunk 3). + +Used by the supervise dashboard when the operator approves an +egress-proxy-block proposal (or runs the operator-initiated +`routes edit ` verb). Fetches the current routes.yaml via +`docker exec cat`, validates the new content, writes it into the +sidecar via `docker cp`, then `docker kill --signal HUP` to make +the addon reload without dropping connections. + +Raises EgressProxyApplyError on any failure — the dashboard +surfaces the message and keeps the proposal pending so the +operator can retry. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from ...egress_proxy import EGRESS_PROXY_ROUTES_IN_CONTAINER +from ...egress_proxy_addon_core import load_routes +from .egress_proxy import egress_proxy_container_name + + +class EgressProxyApplyError(RuntimeError): + """Raised when fetch / apply fails. Caller renders to the + operator; does not crash the dashboard.""" + + +def fetch_current_routes(slug: str) -> str: + """Read the live routes.yaml from the running egress-proxy sidecar + for `slug`. Returns the file content as a string. Raises + EgressProxyApplyError if the sidecar isn't reachable or the read + fails.""" + container = egress_proxy_container_name(slug) + r = subprocess.run( + ["docker", "exec", container, "cat", EGRESS_PROXY_ROUTES_IN_CONTAINER], + capture_output=True, text=True, check=False, + ) + if r.returncode != 0: + raise EgressProxyApplyError( + f"could not read routes.yaml from {container}: " + f"{(r.stderr or '').strip() or 'container not running?'}" + ) + return r.stdout + + +def validate_routes_content(content: str) -> None: + """Syntactic check before SIGHUP — the addon's reload also + validates, but failing here keeps the old routes live and gives + the operator a clearer error than the addon's stderr line.""" + try: + load_routes(content) + except ValueError as e: + raise EgressProxyApplyError( + f"proposed routes.yaml is not valid: {e}" + ) from e + + +def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: + """Apply `new_content` to the egress-proxy sidecar for `slug`: + 1. Fetch current routes.yaml (for the before-diff). + 2. Validate the new content via the addon's own parser. + 3. Write to a temp file, `docker cp` into the sidecar. + 4. `docker kill --signal HUP` so the addon reloads. + + Returns (before, after) where `after` == `new_content`. Raises + EgressProxyApplyError on any step; the existing routes in the + sidecar are unchanged if the failure is before docker cp, and + are reverted in spirit if SIGHUP fails (cp landed but reload + didn't fire — caller's next attempt will SIGHUP again).""" + container = egress_proxy_container_name(slug) + before = fetch_current_routes(slug) + validate_routes_content(new_content) + + fd, tmp_path = tempfile.mkstemp(prefix="cb-routes.", suffix=".yaml") + try: + with os.fdopen(fd, "w") as f: + f.write(new_content) + cp = subprocess.run( + ["docker", "cp", tmp_path, + f"{container}:{EGRESS_PROXY_ROUTES_IN_CONTAINER}"], + capture_output=True, text=True, check=False, + ) + if cp.returncode != 0: + raise EgressProxyApplyError( + f"failed to copy routes.yaml into {container}: " + f"{(cp.stderr or '').strip()}" + ) + sig = subprocess.run( + ["docker", "kill", "--signal", "HUP", container], + capture_output=True, text=True, check=False, + ) + if sig.returncode != 0: + raise EgressProxyApplyError( + f"failed to SIGHUP {container}: " + f"{(sig.stderr or '').strip()}" + ) + finally: + try: + Path(tmp_path).unlink() + except OSError: + pass + + return before, new_content + + +__all__ = [ + "EgressProxyApplyError", + "apply_routes_change", + "fetch_current_routes", + "validate_routes_content", +] diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index de8c154..f41db21 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -1,12 +1,12 @@ """dashboard: list pending supervise proposals across all bottles and act on them (approve / modify / reject). PRD 0013 v1. -Curses-based TUI; modify-then-approve shells out to $EDITOR. For -0013 the approval handlers are no-ops on the supervisor side: the -response file is written (and the sidecar returns it to the agent), -and an audit entry is appended, but no host-side config change runs. -PRDs 0014 (cred-proxy) and 0015 (pipelock) wire in the actual -writes. +Curses-based TUI; modify-then-approve shells out to $EDITOR. The +approval handlers wire to the per-tool remediation engines: +PRD 0014 (egress-proxy, retargeted from cred-proxy in PRD 0017 +chunk 3) writes routes.yaml + SIGHUPs egress-proxy; PRD 0015 +(pipelock) writes the allowlist + restarts pipelock; PRD 0016 +(capability) rebuilds the bottle Dockerfile. """ from __future__ import annotations @@ -27,8 +27,8 @@ from ..backend.docker.capability_apply import ( CapabilityApplyError, apply_capability_change, ) -from ..backend.docker.cred_proxy_apply import ( - CredProxyApplyError, +from ..backend.docker.egress_proxy_apply import ( + EgressProxyApplyError, apply_routes_change, fetch_current_routes, ) @@ -50,7 +50,7 @@ from ..supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, - TOOL_CRED_PROXY_BLOCK, + TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, archive_proposal, list_pending_proposals, @@ -64,7 +64,7 @@ from ._common import PROG # Errors any remediation engine may raise. Caught by the TUI key # handlers and surfaced in the status line so a failed apply keeps # the proposal pending rather than crashing curses. -ApplyError = (CredProxyApplyError, PipelockApplyError, CapabilityApplyError) +ApplyError = (EgressProxyApplyError, PipelockApplyError, CapabilityApplyError) # --- Discovery ------------------------------------------------------------- @@ -103,10 +103,10 @@ def _discover_sidecar_slugs(name_prefix: str) -> list[str]: return sorted(out) -def discover_cred_proxy_slugs() -> list[str]: - """Slugs of bottles with a running cred-proxy sidecar. Used by +def discover_egress_proxy_slugs() -> list[str]: + """Slugs of bottles with a running egress-proxy sidecar. Used by the operator-initiated `routes edit` verb.""" - return _discover_sidecar_slugs("claude-bottle-cred-proxy-") + return _discover_sidecar_slugs("claude-bottle-egress-proxy-") def discover_pipelock_slugs() -> list[str]: @@ -156,16 +156,16 @@ def approve( entry. If `final_file` is provided the status is `modified`; otherwise `approved`. - Raises CredProxyApplyError if the cred-proxy-block apply fails - (sidecar down, invalid JSON survived the operator's modify). - On failure no response is written and no audit entry is - appended — the proposal stays pending so the operator can fix - the input and retry.""" + Raises EgressProxyApplyError if the egress-proxy-block apply + fails (sidecar down, invalid routes content survived the + operator's modify). On failure no response is written and no + audit entry is appended — the proposal stays pending so the + operator can fix the input and retry.""" status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file diff_before, diff_after = "", "" - if qp.proposal.tool == TOOL_CRED_PROXY_BLOCK: + if qp.proposal.tool == TOOL_EGRESS_PROXY_BLOCK: diff_before, diff_after = apply_routes_change( qp.proposal.bottle_slug, file_to_apply, ) @@ -212,22 +212,22 @@ def reject(qp: QueuedProposal, *, reason: str) -> None: def operator_edit_routes(slug: str, new_content: str) -> tuple[str, str]: - """Apply an operator-initiated routes.json change (no agent + """Apply an operator-initiated routes.yaml change (no agent proposal). Used by the `routes edit ` TUI verb and available for scripted use. Returns (before, after) like apply_routes_change. Writes an audit entry tagged ACTION_OPERATOR_EDIT to distinguish from tool-call approvals. - Raises CredProxyApplyError on failure.""" + Raises EgressProxyApplyError on failure.""" before, after = apply_routes_change(slug, new_content) write_audit_entry(AuditEntry( timestamp=datetime.now(timezone.utc).isoformat(), bottle_slug=slug, - component="cred-proxy", + component="egress-proxy", operator_action=ACTION_OPERATOR_EDIT, operator_notes="", justification="", - diff=render_diff(before, after, label="cred-proxy"), + diff=render_diff(before, after, label="egress-proxy"), )) return before, after @@ -239,20 +239,19 @@ def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]: The full URL (with path) is preserved on the proposal for the operator's read; only the host ends up in pipelock's allowlist. - FOLLOW-UP — path-aware filtering. Pipelock 2.3.0's api_allowlist - is hostname-only (verified by inspecting the binary's strict - preset; the only "path" fields in pipelock's schema are about - local filesystem paths under sandbox / file_sentry / taint). So - approving pipelock-block opens the entire host, not the URL's - path. If/when per-path enforcement becomes load-bearing, the - follow-up is most likely adding an `auth_scheme: none` mode + - `path_allowlist` field to cred-proxy (which already does - path-prefix routing) and rewiring pipelock-block to propose - cred-proxy routes instead of pipelock hostnames. That's a - multi-touch change deserving its own PRD — out of scope for the - supervise-loop work that introduced this function. See PR - discussion on https://gitea.dideric.is/didericis/claude-bottle/pulls/25 - for the design conversation.""" + Pipelock 2.3.0's api_allowlist is hostname-only (verified by + inspecting the binary's strict preset; the only "path" fields in + pipelock's schema are about local filesystem paths under sandbox + / file_sentry / taint). Approving pipelock-block opens the + entire host, not the URL's path. + + Path-level enforcement was the open question this function's + earlier docstring flagged; PRD 0017 answered it by putting + egress-proxy in front of pipelock. The agent's + `egress-proxy-block` tool now proposes routes.yaml changes that + can include a `path_allowlist`. Use that tool for path-level + follow-ups; this one stays hostname-only because pipelock is + still the last hostname gate before egress.""" import urllib.parse parsed = urllib.parse.urlsplit(failed_url.strip()) host = parsed.hostname or "" @@ -296,14 +295,13 @@ def _write_audit( diff_before: str, diff_after: str, ) -> None: - """Audit log for cred-proxy / pipelock tools. capability-block has - no audit log (its changes are captured by the bottle's rebuild - record + git history per PRD 0016). + """Audit log for egress-proxy / pipelock tools. capability-block + has no audit log (its changes are captured by the bottle's + rebuild record + git history per PRD 0016). - For cred-proxy-block approvals the (before, after) come from the - apply_routes_change return — a real fetched-from-sidecar diff. - For rejections, or for tools whose remediation hasn't landed yet - (pipelock in 0014, capability anywhere), both are empty strings + For egress-proxy-block + pipelock-block approvals the (before, + after) come from the apply_*_change return — a real + fetched-from-sidecar diff. For rejections both are empty strings and the audit diff renders as empty.""" component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) if component is None: @@ -683,22 +681,22 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: def _suffix_for_tool(tool: str) -> str: if tool == TOOL_CAPABILITY_BLOCK: return ".dockerfile" - # cred-proxy-block / pipelock-block: JSON-ish + plain. + # egress-proxy-block / pipelock-block: JSON-ish + plain. return ".txt" def _operator_edit_routes_flow(stdscr: "curses._CursesWindow") -> str: - """Operator-initiated routes.json edit. Discover running - cred-proxy sidecars, pick one (single → use directly; multi → + """Operator-initiated routes.yaml edit. Discover running + egress-proxy sidecars, pick one (single → use directly; multi → prompt), fetch the current routes, open in $EDITOR, apply on save. Returns a status-line message.""" return _operator_edit_flow( stdscr, label="routes", - discover=discover_cred_proxy_slugs, + discover=discover_egress_proxy_slugs, fetch=fetch_current_routes, apply=operator_edit_routes, - suffix=".json", + suffix=".yaml", ) diff --git a/claude_bottle/supervise.py b/claude_bottle/supervise.py index cb07241..08a05ab 100644 --- a/claude_bottle/supervise.py +++ b/claude_bottle/supervise.py @@ -5,7 +5,7 @@ queue/audit support. The sidecar (claude_bottle.supervise_server) sits on the bottle's internal network and exposes three MCP tools the agent calls when it hits a stuck-recovery category: - * cred-proxy-block — agent proposes a new routes.json + * egress-proxy-block — agent proposes a new routes.yaml * pipelock-block — agent proposes a new pipelock allowlist * capability-block — agent proposes a new agent Dockerfile @@ -49,11 +49,11 @@ from pathlib import Path SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 -TOOL_CRED_PROXY_BLOCK = "cred-proxy-block" +TOOL_EGRESS_PROXY_BLOCK = "egress-proxy-block" TOOL_PIPELOCK_BLOCK = "pipelock-block" TOOL_CAPABILITY_BLOCK = "capability-block" TOOLS: tuple[str, ...] = ( - TOOL_CRED_PROXY_BLOCK, + TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK, ) @@ -63,7 +63,7 @@ TOOLS: tuple[str, ...] = ( # here — those changes are captured by git history + the rebuild # record laid down in PRD 0016. COMPONENT_FOR_TOOL: dict[str, str] = { - TOOL_CRED_PROXY_BLOCK: "cred-proxy", + TOOL_EGRESS_PROXY_BLOCK: "egress-proxy", TOOL_PIPELOCK_BLOCK: "pipelock", } @@ -566,7 +566,7 @@ __all__ = [ "SupervisePlan", "TOOLS", "TOOL_CAPABILITY_BLOCK", - "TOOL_CRED_PROXY_BLOCK", + "TOOL_EGRESS_PROXY_BLOCK", "TOOL_PIPELOCK_BLOCK", "archive_proposal", "audit_dir", diff --git a/claude_bottle/supervise_server.py b/claude_bottle/supervise_server.py index e98b70a..1517d4c 100644 --- a/claude_bottle/supervise_server.py +++ b/claude_bottle/supervise_server.py @@ -1,6 +1,6 @@ """Supervise sidecar HTTP server (PRD 0013). -Per-bottle MCP server exposing three tools — `cred-proxy-block`, +Per-bottle MCP server exposing three tools — `egress-proxy-block`, `pipelock-block`, `capability-block` — that the agent calls to propose config changes when stuck. Each tool call: @@ -128,26 +128,27 @@ def jsonrpc_error(request_id: object, code: int, message: str) -> bytes: TOOL_DEFINITIONS: list[dict[str, object]] = [ { - "name": _sv.TOOL_CRED_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_PROXY_BLOCK, "description": ( - "Call when cred-proxy refused your HTTPS request — missing " - "route, expired token, wrong scope (typically a 403 or a " - "404 from `http://cred-proxy://`). Read the " - "current routes.json from " - "/etc/claude-bottle/current-config/routes.json, compose a " - "modified version with the route you need, and pass the " - "full new file plus a justification. The operator approves " - "or rejects in the supervise TUI. On approval the supervisor " - "writes the new routes.json on the host and SIGHUPs cred-proxy " - "(wired in PRD 0014; in the v1 supervise foundation the " - "approval is acknowledged but no config change runs)." + "Call when egress-proxy refused your HTTPS request — host " + "without a matching route, or a path outside the route's " + "path_allowlist (typically a 403 from the proxy). Read " + "the current routes.yaml from " + "/etc/claude-bottle/current-config/routes.yaml, compose " + "a modified version that adds or relaxes the route you " + "need, and pass the full new file plus a justification. " + "The operator approves or rejects in the supervise TUI. " + "On approval the supervisor writes the new routes.yaml " + "on the host and SIGHUPs egress-proxy (the addon's reload " + "swaps the route table atomically without dropping " + "in-flight connections)." ), "inputSchema": { "type": "object", "properties": { "routes": { "type": "string", - "description": "Full proposed routes.json file content (JSON text).", + "description": "Full proposed routes.yaml file content (JSON text — every JSON document is valid YAML).", }, "justification": { "type": "string", @@ -226,15 +227,15 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ # tool-specific payload (stored in Proposal.proposed_file as # free-form text the apply path interprets per tool). # -# cred-proxy-block: full proposed routes.json -# pipelock-block: the full failed URL (scheme + host + path) — -# supervisor extracts the host, merges into the -# bottle's current allowlist; the path is shown -# to the operator for context (pipelock doesn't -# do path-level matching). -# capability-block: full proposed Dockerfile +# egress-proxy-block: full proposed routes.yaml +# pipelock-block: the full failed URL (scheme + host + path) — +# supervisor extracts the host, merges into the +# bottle's current allowlist; the path is shown +# to the operator for context (pipelock doesn't +# do path-level matching). +# capability-block: full proposed Dockerfile PROPOSED_FILE_FIELD: dict[str, str] = { - _sv.TOOL_CRED_PROXY_BLOCK: "routes", + _sv.TOOL_EGRESS_PROXY_BLOCK: "routes", _sv.TOOL_PIPELOCK_BLOCK: "failed_url", _sv.TOOL_CAPABILITY_BLOCK: "dockerfile", } @@ -249,18 +250,18 @@ def validate_proposed_file(tool: str, content: str) -> None: enter the queue.""" if not content.strip(): raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty") - if tool == _sv.TOOL_CRED_PROXY_BLOCK: + if tool == _sv.TOOL_EGRESS_PROXY_BLOCK: try: parsed = json.loads(content) except json.JSONDecodeError as e: raise _RpcError( ERR_INVALID_PARAMS, - f"{tool}: proposed routes.json is not valid JSON: {e}", + f"{tool}: proposed routes.yaml is not valid JSON: {e}", ) from e if not isinstance(parsed, dict) or not isinstance(parsed.get("routes"), list): raise _RpcError( ERR_INVALID_PARAMS, - f"{tool}: proposed routes.json must be an object with a 'routes' array", + f"{tool}: proposed routes.yaml must be an object with a 'routes' array", ) elif tool == _sv.TOOL_PIPELOCK_BLOCK: # `content` is the full failed URL. Require scheme + host so @@ -505,7 +506,7 @@ def serve( def main(argv: list[str]) -> int: - del argv # config is env-only, matches cred_proxy_server pattern + del argv # config is env-only, no CLI flags bottle_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "") if not bottle_slug: sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n") diff --git a/docs/prds/0010-cred-proxy.md b/docs/prds/0010-cred-proxy.md index b375130..7716519 100644 --- a/docs/prds/0010-cred-proxy.md +++ b/docs/prds/0010-cred-proxy.md @@ -1,8 +1,16 @@ # PRD 0010: Credential proxy for agent-bound API tokens -- **Status:** Draft +- **Status:** Superseded by [PRD 0017](0017-egress-proxy-via-mitmproxy.md) - **Author:** didericis - **Created:** 2026-05-13 +- **Superseded:** 2026-05-25 + +> **Historical reference only.** The cred-proxy sidecar this PRD +> describes was replaced by the egress-proxy sidecar (PRD 0017) in +> a hard cutover. The auth-injection role moved over largely intact; +> path-prefix routing is replaced by universal MITM at the agent's +> HTTP_PROXY. See PRD 0017's "Migration — hard cutover" section for +> the field-by-field manifest rename. ## Summary diff --git a/docs/prds/0014-cred-proxy-block-remediation.md b/docs/prds/0014-cred-proxy-block-remediation.md index 13dd221..bc419e9 100644 --- a/docs/prds/0014-cred-proxy-block-remediation.md +++ b/docs/prds/0014-cred-proxy-block-remediation.md @@ -1,11 +1,23 @@ # PRD 0014: cred-proxy block remediation -- **Status:** Draft +- **Status:** Retargeted by [PRD 0017](0017-egress-proxy-via-mitmproxy.md) - **Author:** didericis - **Created:** 2026-05-25 +- **Retargeted:** 2026-05-25 - **Parent:** PRD 0012 - **Depends on:** PRD 0013 +> **Retarget notice.** The remediation shape this PRD describes (MCP +> tool → operator approve → SIGHUP a sidecar) is intact, but the +> sidecar moved: cred-proxy is gone, replaced by egress-proxy under +> PRD 0017. The MCP tool is now named `egress-proxy-block`; the +> proposed file is `routes.yaml` (JSON content) in egress-proxy's +> route shape (host + path_allowlist + nested `auth` block); the +> apply path docker-cps + SIGHUPs egress-proxy. The audit-log +> component label changed from `cred-proxy` to `egress-proxy`. +> Operator-initiated `routes edit ` still exists with the +> same UX, now pointed at the egress-proxy sidecar. + ## Summary Wires the **cred-proxy block** path (PRD 0012 *Stuck categories*) end-to-end. cred-proxy gains SIGHUP-based hot reload of `routes.json`. The supervisor, on approval of a `cred-proxy-block` proposal, writes the new `routes.json` to the host and SIGHUPs cred-proxy — no restart, no dropped connections. The TUI gains a proactive `routes edit ` verb for operator-initiated edits unrelated to a tool call. The cred-proxy audit log (format defined in PRD 0013) is filled in with real entries on every edit. diff --git a/tests/integration/test_supervise_sidecar.py b/tests/integration/test_supervise_sidecar.py index 093c347..b4492c3 100644 --- a/tests/integration/test_supervise_sidecar.py +++ b/tests/integration/test_supervise_sidecar.py @@ -196,7 +196,7 @@ class TestSuperviseSidecar(unittest.TestCase): names = {t["name"] for t in result["result"]["tools"]} self.assertEqual( { - _sv.TOOL_CRED_PROXY_BLOCK, + _sv.TOOL_EGRESS_PROXY_BLOCK, _sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK, }, @@ -204,28 +204,38 @@ class TestSuperviseSidecar(unittest.TestCase): ) def test_tools_call_round_trips_through_queue(self): - """End-to-end: agent in the bottle calls cred-proxy-block; - the call blocks on the queue; the host rejects via the - dashboard helpers; the agent receives the rejection. + """End-to-end: agent in the bottle calls egress-proxy-block; + the call blocks on the queue; the host approves via the + dashboard helpers; the agent receives the approval. - PRD 0017 chunk 2 deleted the cred-proxy sidecar, so the - approval-apply path on cred-proxy-block is broken in this - intermediate state (chunk 3 retargets it at egress-proxy and - restores the round-trip approval test). For now this verifies - only the queue + response leg by exercising the reject path - — no docker-exec into a sidecar needed.""" + This test focuses on the supervise sidecar's queue + response + plumbing, not the egress-proxy apply path itself. The apply + function is stubbed so we don't need to bring up a real + egress-proxy sidecar (its docker lifecycle has its own + integration coverage).""" self._require_bind_mount_sharing() self._bring_up_sidecar() + # Stub the apply step. The dashboard's approve() calls + # apply_routes_change to docker-exec into the egress-proxy + # sidecar; this test isn't exercising the real sidecar, so + # patch it to a no-op that returns plausible before/after + # strings the audit-log writer can render. + from claude_bottle.cli import dashboard as _dash + original_apply = _dash.apply_routes_change + _dash.apply_routes_change = ( + lambda slug, new: ("(stubbed before)", new) + ) + captured: dict[str, object] = {} def caller() -> None: captured["response"] = self._curl_jsonrpc({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { - "name": _sv.TOOL_CRED_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_PROXY_BLOCK, "arguments": { - "routes": '{"routes": [{"path": "/x/"}]}', + "routes": '{"routes": [{"host": "api.example.com"}]}', "justification": "integration test", }, }, @@ -249,16 +259,17 @@ class TestSuperviseSidecar(unittest.TestCase): self.assertIsNotNone(qp, "proposal never appeared in queue") assert qp is not None # type-narrowing self.assertEqual( - _sv.TOOL_CRED_PROXY_BLOCK, qp.proposal.tool, + _sv.TOOL_EGRESS_PROXY_BLOCK, qp.proposal.tool, ) self.assertEqual("integration test", qp.proposal.justification) - # Reject via the dashboard helper. The reject path skips - # the sidecar-apply step, so it works without a real - # cred-proxy sidecar (which doesn't exist in chunk 2's - # transitional state). - dashboard.reject(qp, reason="no real cred-proxy in chunk 2") + # Approve via the dashboard helper. The apply step (now + # stubbed) would docker-exec into the egress-proxy sidecar + # and SIGHUP it. The supervise sidecar sees the response + # file and returns to the curl caller. + dashboard.approve(qp, notes="lgtm from integration test") finally: + _dash.apply_routes_change = original_apply t.join(timeout=20) response = captured.get("response") @@ -267,12 +278,10 @@ class TestSuperviseSidecar(unittest.TestCase): self.assertEqual(7, response["id"]) result = response["result"] assert isinstance(result, dict) - # Rejected tool calls surface as MCP errors so the agent - # treats them as failures (not silent successes). - self.assertTrue(result.get("isError")) + self.assertFalse(result.get("isError")) text = result["content"][0]["text"] - self.assertIn("rejected", text) - self.assertIn("no real cred-proxy", text) + self.assertIn("status: approved", text) + self.assertIn("notes: lgtm from integration test", text) def test_orphan_sidecar_name_collision_recovered(self): """An orphan supervise sidecar from a previous run blocks diff --git a/tests/unit/test_cred_proxy_apply.py b/tests/unit/test_cred_proxy_apply.py deleted file mode 100644 index b0877d2..0000000 --- a/tests/unit/test_cred_proxy_apply.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Unit: validate_routes_json (PRD 0014 Phase 2). docker exec / cp / -kill paths are covered by the integration test.""" - -import unittest - -from claude_bottle.backend.docker.cred_proxy_apply import ( - CredProxyApplyError, - validate_routes_json, -) - - -class TestValidateRoutesJson(unittest.TestCase): - def test_accepts_routes_array(self): - validate_routes_json('{"routes": []}') - validate_routes_json( - '{"routes": [{"path": "/a/", "upstream": "https://example.com",' - ' "auth_scheme": "Bearer", "token_env": "T0"}]}' - ) - - def test_rejects_bad_json(self): - with self.assertRaises(CredProxyApplyError) as cm: - validate_routes_json("{not json") - self.assertIn("not valid JSON", str(cm.exception)) - - def test_rejects_non_object_top_level(self): - with self.assertRaises(CredProxyApplyError): - validate_routes_json("[]") - - def test_rejects_missing_routes_key(self): - with self.assertRaises(CredProxyApplyError): - validate_routes_json('{"other": []}') - - def test_rejects_non_list_routes(self): - with self.assertRaises(CredProxyApplyError): - validate_routes_json('{"routes": "not a list"}') - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 3d0ce60..80e8c24 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -17,7 +17,7 @@ from pathlib import Path from claude_bottle import supervise from claude_bottle.backend.docker.capability_apply import CapabilityApplyError -from claude_bottle.backend.docker.cred_proxy_apply import CredProxyApplyError +from claude_bottle.backend.docker.egress_proxy_apply import EgressProxyApplyError from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError from claude_bottle.cli import dashboard from claude_bottle.supervise import ( @@ -26,7 +26,7 @@ from claude_bottle.supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, - TOOL_CRED_PROXY_BLOCK, + TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, read_audit_entries, read_response, @@ -37,13 +37,13 @@ from claude_bottle.supervise import ( FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) -def _proposal(slug: str = "dev", tool: str = TOOL_CRED_PROXY_BLOCK) -> Proposal: +def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_PROXY_BLOCK) -> Proposal: # Per-tool payload shape: cred-proxy gets routes.json, pipelock # gets a failed URL (PR #25 follow-up), capability gets a # Dockerfile-ish blob. Match the production dispatch in # PROPOSED_FILE_FIELD. payloads = { - TOOL_CRED_PROXY_BLOCK: '{"routes": []}\n', + TOOL_EGRESS_PROXY_BLOCK: '{"routes": []}\n', TOOL_PIPELOCK_BLOCK: "https://example.com/path", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", } @@ -95,13 +95,13 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): def test_sorted_by_arrival_across_bottles(self): early = Proposal.new( - bottle_slug="api", tool=TOOL_CRED_PROXY_BLOCK, + bottle_slug="api", tool=TOOL_EGRESS_PROXY_BLOCK, proposed_file="{}", justification="early", current_file_hash="h", now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), ) late = Proposal.new( - bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, proposed_file="{}", justification="late", current_file_hash="h", now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), @@ -151,7 +151,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): dashboard.apply_capability_change = self._original_apply_capability self._teardown_fake_home() - def _enqueue(self, tool: str = TOOL_CRED_PROXY_BLOCK): + def _enqueue(self, tool: str = TOOL_EGRESS_PROXY_BLOCK): p = _proposal(tool=tool) qdir = supervise.queue_dir_for_slug("dev") qdir.mkdir(parents=True, exist_ok=True) @@ -164,7 +164,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_APPROVED, resp.status) self.assertIsNone(resp.final_file) - entries = read_audit_entries("cred-proxy", "dev") + entries = read_audit_entries("egress-proxy", "dev") self.assertEqual(1, len(entries)) self.assertEqual("approved", entries[0].operator_action) @@ -175,7 +175,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file) self.assertEqual("tweaked", resp.notes) - entries = read_audit_entries("cred-proxy", "dev") + entries = read_audit_entries("egress-proxy", "dev") self.assertEqual("modified", entries[0].operator_action) def test_reject_writes_rejection(self): @@ -184,7 +184,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual("nope", resp.notes) - entries = read_audit_entries("cred-proxy", "dev") + entries = read_audit_entries("egress-proxy", "dev") self.assertEqual("rejected", entries[0].operator_action) self.assertEqual("nope", entries[0].operator_notes) @@ -193,18 +193,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): dashboard.approve(qp) # No audit log for capability-block (per PRD 0013 / 0016). # cred-proxy and pipelock logs both empty. - self.assertEqual([], read_audit_entries("cred-proxy", "dev")) + self.assertEqual([], read_audit_entries("egress-proxy", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev")) - def test_pipelock_audit_distinct_from_cred_proxy(self): + def test_pipelock_audit_distinct_from_egress_proxy(self): qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) dashboard.approve(qp) self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) - self.assertEqual(0, len(read_audit_entries("cred-proxy", "dev"))) + self.assertEqual(0, len(read_audit_entries("egress-proxy", "dev"))) -class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): - """PRD 0014 Phase 3: approve() on a cred-proxy-block proposal +class TestEgressProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): + """PRD 0014 Phase 3: approve() on a egress-proxy-block proposal must call apply_routes_change with the right args and surface its failures.""" @@ -216,9 +216,9 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.apply_routes_change = self._original_apply self._teardown_fake_home() - def _enqueue_cred_proxy(self, proposed: str = '{"routes": []}\n'): + def _enqueue_egress_proxy(self, proposed: str = '{"routes": []}\n'): p = Proposal.new( - bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, proposed_file=proposed, justification="need a route", current_file_hash=sha256_hex(proposed), @@ -229,12 +229,12 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): supervise.write_proposal(qdir, p) return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) - def test_cred_proxy_block_calls_apply_with_proposed_file(self): + def test_egress_proxy_block_calls_apply_with_proposed_file(self): calls = [] dashboard.apply_routes_change = lambda slug, content: ( calls.append((slug, content)) or ("before", content) ) - qp = self._enqueue_cred_proxy(proposed='{"routes": [{"path": "/new/"}]}\n') + qp = self._enqueue_egress_proxy(proposed='{"routes": [{"path": "/new/"}]}\n') dashboard.approve(qp) self.assertEqual(1, len(calls)) slug, content = calls[0] @@ -246,16 +246,16 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.apply_routes_change = lambda slug, content: ( calls.append(content) or ("before", content) ) - qp = self._enqueue_cred_proxy() + qp = self._enqueue_egress_proxy() dashboard.approve(qp, final_file='{"routes": [{"path": "/edited/"}]}\n', notes="tweaked") self.assertEqual(['{"routes": [{"path": "/edited/"}]}\n'], calls) def test_apply_failure_blocks_response_and_audit(self): dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw( - CredProxyApplyError("docker exec failed") + EgressProxyApplyError("docker exec failed") ) - qp = self._enqueue_cred_proxy() - with self.assertRaises(CredProxyApplyError): + qp = self._enqueue_egress_proxy() + with self.assertRaises(EgressProxyApplyError): dashboard.approve(qp) # No response file (proposal stays pending). self.assertEqual( @@ -263,16 +263,16 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], ) # No audit entry. - self.assertEqual([], read_audit_entries("cred-proxy", "dev")) + self.assertEqual([], read_audit_entries("egress-proxy", "dev")) def test_real_diff_lands_in_audit(self): dashboard.apply_routes_change = lambda slug, content: ( '{"routes": []}\n', # before '{"routes": [{"path": "/new/"}]}\n', # after ) - qp = self._enqueue_cred_proxy(proposed='{"routes": [{"path": "/new/"}]}\n') + qp = self._enqueue_egress_proxy(proposed='{"routes": [{"path": "/new/"}]}\n') dashboard.approve(qp) - entries = read_audit_entries("cred-proxy", "dev") + entries = read_audit_entries("egress-proxy", "dev") self.assertEqual(1, len(entries)) self.assertIn('+{"routes": [{"path": "/new/"}]}', entries[0].diff) self.assertIn('-{"routes": []}', entries[0].diff) @@ -282,13 +282,13 @@ class TestCredProxyApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.apply_routes_change = lambda slug, content: ( called.append(True) or ("", content) ) - qp = self._enqueue_cred_proxy() + qp = self._enqueue_egress_proxy() dashboard.reject(qp, reason="no thanks") self.assertEqual([], called) # Reject still writes a response + audit entry with empty diff. resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_REJECTED, resp.status) - entries = read_audit_entries("cred-proxy", "dev") + entries = read_audit_entries("egress-proxy", "dev") self.assertEqual(1, len(entries)) self.assertEqual("", entries[0].diff) @@ -432,7 +432,7 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): dashboard.approve(qp) # capability-block has no audit log per PRD 0013 — its record # lives in the per-bottle Dockerfile + transcript state. - self.assertEqual([], read_audit_entries("cred-proxy", "dev")) + self.assertEqual([], read_audit_entries("egress-proxy", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev")) def test_proposal_archived_after_apply(self): @@ -464,7 +464,7 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase): '{"routes": []}\n', content, ) dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n') - entries = read_audit_entries("cred-proxy", "dev") + entries = read_audit_entries("egress-proxy", "dev") self.assertEqual(1, len(entries)) self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action) self.assertEqual("", entries[0].justification) @@ -472,14 +472,14 @@ class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase): def test_failure_does_not_write_audit(self): dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw( - CredProxyApplyError("nope") + EgressProxyApplyError("nope") ) - with self.assertRaises(CredProxyApplyError): + with self.assertRaises(EgressProxyApplyError): dashboard.operator_edit_routes("dev", '{"routes": []}\n') - self.assertEqual([], read_audit_entries("cred-proxy", "dev")) + self.assertEqual([], read_audit_entries("egress-proxy", "dev")) -class TestDiscoverCredProxySlugs(unittest.TestCase): +class TestDiscoverEgressProxySlugs(unittest.TestCase): """Slug-extraction parsing — exercises only the parsing path; the docker ps invocation itself is environment-dependent (and tested implicitly by the integration test).""" @@ -491,7 +491,7 @@ class TestDiscoverCredProxySlugs(unittest.TestCase): original = os.environ.get("PATH", "") os.environ["PATH"] = "/nonexistent-no-docker-here" try: - self.assertEqual([], dashboard.discover_cred_proxy_slugs()) + self.assertEqual([], dashboard.discover_egress_proxy_slugs()) self.assertEqual([], dashboard.discover_pipelock_slugs()) finally: os.environ["PATH"] = original diff --git a/tests/unit/test_dashboard_detail_lines.py b/tests/unit/test_dashboard_detail_lines.py index 918ceb3..096cfbe 100644 --- a/tests/unit/test_dashboard_detail_lines.py +++ b/tests/unit/test_dashboard_detail_lines.py @@ -12,7 +12,7 @@ from claude_bottle.cli import dashboard from claude_bottle.supervise import ( Proposal, TOOL_CAPABILITY_BLOCK, - TOOL_CRED_PROXY_BLOCK, + TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, sha256_hex, ) @@ -46,9 +46,9 @@ class TestPipelockHostHighlight(unittest.TestCase): green_lines = [text for text, attr in lines if attr == self.GREEN] self.assertEqual(["api.github.com"], green_lines) - def test_no_green_lines_for_cred_proxy_block(self): + def test_no_green_lines_for_egress_proxy_block(self): lines = dashboard._detail_lines( - _qp(TOOL_CRED_PROXY_BLOCK, '{"routes": []}'), + _qp(TOOL_EGRESS_PROXY_BLOCK, '{"routes": []}'), green_attr=self.GREEN, ) self.assertEqual([], [t for t, a in lines if a == self.GREEN]) diff --git a/tests/unit/test_egress_proxy_apply.py b/tests/unit/test_egress_proxy_apply.py new file mode 100644 index 0000000..eb47b4c --- /dev/null +++ b/tests/unit/test_egress_proxy_apply.py @@ -0,0 +1,56 @@ +"""Unit: validate_routes_content (PRD 0014 retargeted by PRD 0017 +chunk 3). docker exec / cp / kill paths are covered by the +integration test.""" + +import unittest + +from claude_bottle.backend.docker.egress_proxy_apply import ( + EgressProxyApplyError, + validate_routes_content, +) + + +class TestValidateRoutesContent(unittest.TestCase): + def test_accepts_minimal_route_table(self): + validate_routes_content('{"routes": []}') + validate_routes_content( + '{"routes": [{"host": "api.github.com"}]}' + ) + + def test_accepts_full_route(self): + validate_routes_content( + '{"routes": [{"host": "api.github.com",' + ' "path_allowlist": ["/repos/x/"],' + ' "auth_scheme": "Bearer",' + ' "token_env": "EGRESS_PROXY_TOKEN_0"}]}' + ) + + def test_rejects_bad_json(self): + with self.assertRaises(EgressProxyApplyError) as cm: + validate_routes_content("{not json") + self.assertIn("not valid", str(cm.exception)) + + def test_rejects_non_object_top_level(self): + with self.assertRaises(EgressProxyApplyError): + validate_routes_content("[]") + + def test_rejects_missing_routes_key(self): + with self.assertRaises(EgressProxyApplyError): + validate_routes_content('{"other": []}') + + def test_rejects_non_list_routes(self): + with self.assertRaises(EgressProxyApplyError): + validate_routes_content('{"routes": "not a list"}') + + def test_rejects_partial_auth_pair(self): + # The addon-core parser enforces both-or-neither — the apply + # path picks this up before SIGHUP'ing the sidecar. + with self.assertRaises(EgressProxyApplyError): + validate_routes_content( + '{"routes": [{"host": "x.example",' + ' "auth_scheme": "Bearer"}]}' + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 90f54f6..f24baa2 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -17,7 +17,7 @@ from claude_bottle.supervise import ( STATUS_MODIFIED, STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, - TOOL_CRED_PROXY_BLOCK, + TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, archive_proposal, audit_log_path, @@ -37,7 +37,7 @@ from claude_bottle.supervise import ( FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) -def _proposal(tool: str = TOOL_CRED_PROXY_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal: +def _proposal(tool: str = TOOL_EGRESS_PROXY_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal: return Proposal.new( bottle_slug="dev", tool=tool, @@ -54,7 +54,7 @@ class TestProposalRoundtrip(unittest.TestCase): self.assertTrue(p.id) self.assertEqual("2026-05-25T12:00:00+00:00", p.arrival_timestamp) self.assertEqual("dev", p.bottle_slug) - self.assertEqual(TOOL_CRED_PROXY_BLOCK, p.tool) + self.assertEqual(TOOL_EGRESS_PROXY_BLOCK, p.tool) def test_to_from_dict_roundtrip(self): p = _proposal() @@ -139,13 +139,13 @@ class TestQueueIO(unittest.TestCase): def test_list_pending_sorted_by_arrival(self): # Fabricate two with explicit timestamps. a = Proposal.new( - bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, proposed_file="{}", justification="early", current_file_hash="x", now=datetime(2026, 5, 25, 10, 0, 0, tzinfo=timezone.utc), ) b = Proposal.new( - bottle_slug="dev", tool=TOOL_CRED_PROXY_BLOCK, + bottle_slug="dev", tool=TOOL_EGRESS_PROXY_BLOCK, proposed_file="{}", justification="late", current_file_hash="x", now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc), @@ -314,12 +314,12 @@ class TestDiffAndHash(unittest.TestCase): class TestToolConstants(unittest.TestCase): def test_tools_tuple_matches_individual_constants(self): self.assertEqual( - (TOOL_CRED_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK), + (TOOL_EGRESS_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK), supervise.TOOLS, ) def test_component_map_covers_two_remediation_tools_only(self): - self.assertIn(TOOL_CRED_PROXY_BLOCK, supervise.COMPONENT_FOR_TOOL) + self.assertIn(TOOL_EGRESS_PROXY_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL) diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index e77f9bc..ef7616c 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -45,19 +45,19 @@ from claude_bottle.supervise_server import ( class TestValidation(unittest.TestCase): - def test_cred_proxy_block_requires_valid_json(self): + def test_egress_proxy_block_requires_valid_json(self): with self.assertRaises(_RpcError) as cm: - validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, "{not json") + validate_proposed_file(_sv.TOOL_EGRESS_PROXY_BLOCK, "{not json") self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) self.assertIn("not valid JSON", cm.exception.message) - def test_cred_proxy_block_requires_routes_array(self): + def test_egress_proxy_block_requires_routes_array(self): with self.assertRaises(_RpcError): - validate_proposed_file(_sv.TOOL_CRED_PROXY_BLOCK, '{"other": []}') + validate_proposed_file(_sv.TOOL_EGRESS_PROXY_BLOCK, '{"other": []}') - def test_cred_proxy_block_accepts_valid_routes(self): + def test_egress_proxy_block_accepts_valid_routes(self): validate_proposed_file( - _sv.TOOL_CRED_PROXY_BLOCK, + _sv.TOOL_EGRESS_PROXY_BLOCK, '{"routes": [{"path": "/x/", "upstream": "https://example.com"}]}', ) @@ -175,7 +175,7 @@ class TestHandleToolsList(unittest.TestCase): names = [t["name"] for t in result["tools"]] # type: ignore[index] self.assertEqual( sorted([ - _sv.TOOL_CRED_PROXY_BLOCK, + _sv.TOOL_EGRESS_PROXY_BLOCK, _sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK, ]), @@ -225,7 +225,7 @@ class TestHandleToolsCall(unittest.TestCase): try: result = handle_tools_call( { - "name": _sv.TOOL_CRED_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_PROXY_BLOCK, "arguments": { "routes": '{"routes": []}', "justification": "need a route", @@ -269,7 +269,7 @@ class TestHandleToolsCall(unittest.TestCase): with self.assertRaises(_RpcError): handle_tools_call( { - "name": _sv.TOOL_CRED_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_PROXY_BLOCK, "arguments": {"routes": '{"routes": []}'}, }, self.config, @@ -280,7 +280,7 @@ class TestHandleToolsCall(unittest.TestCase): try: handle_tools_call( { - "name": _sv.TOOL_CRED_PROXY_BLOCK, + "name": _sv.TOOL_EGRESS_PROXY_BLOCK, "arguments": { "routes": '{"routes": []}', "justification": "x", @@ -367,7 +367,7 @@ class TestHttpEndToEnd(unittest.TestCase): self.assertEqual("2.0", result["jsonrpc"]) self.assertEqual(1, result["id"]) names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index] - self.assertIn(_sv.TOOL_CRED_PROXY_BLOCK, names) + self.assertIn(_sv.TOOL_EGRESS_PROXY_BLOCK, names) def test_unknown_method_returns_jsonrpc_error(self): result = self._post_jsonrpc(