From 9c1fcd2fe1a4941d03d8e2ff548dcc3c62481bc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:03:10 +0000 Subject: [PATCH 1/3] feat(python): add uvx-installable Python sealg CLI alongside the Rust client A pure-Python client for the SealGate gateway exposing the same `sealg` surface (doctor | list | call) as the Rust binary, packaged so it installs via `uvx`/`pip`. It is a thin MCP-over-HTTP client that forwards tools/list and tools/call to the per-user gateway endpoint, where all policy is enforced. - python/sealg/: client.py (transport), cli.py (typer, exit codes 0/1/6), contract.py (single source of truth for wire constants); tests ported from the Rust suite; README + .env.example. - scripts/check_wire_contract.py + prek hook + .github/workflows/python-cli.yaml: an in-repo drift guard that reads the Rust source in crates/ and the Python contract and fails if any shared constant (protocol version, headers, env keys, /mcp/{key}/ path shape, exit codes) diverges. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- .github/workflows/python-cli.yaml | 56 ++++++ prek.toml | 14 ++ python/.env.example | 24 +++ python/README.md | 41 ++++ python/pyproject.toml | 20 ++ python/sealg/__init__.py | 1 + python/sealg/cli.py | 145 ++++++++++++++ python/sealg/client.py | 312 ++++++++++++++++++++++++++++++ python/sealg/contract.py | 63 ++++++ python/tests/test_wire.py | 187 ++++++++++++++++++ scripts/check_wire_contract.py | 218 +++++++++++++++++++++ 11 files changed, 1081 insertions(+) create mode 100644 .github/workflows/python-cli.yaml create mode 100644 python/.env.example create mode 100644 python/README.md create mode 100644 python/pyproject.toml create mode 100644 python/sealg/__init__.py create mode 100644 python/sealg/cli.py create mode 100644 python/sealg/client.py create mode 100644 python/sealg/contract.py create mode 100644 python/tests/test_wire.py create mode 100644 scripts/check_wire_contract.py diff --git a/.github/workflows/python-cli.yaml b/.github/workflows/python-cli.yaml new file mode 100644 index 0000000..4409b96 --- /dev/null +++ b/.github/workflows/python-cli.yaml @@ -0,0 +1,56 @@ +name: Python CLI + +on: + workflow_dispatch: + pull_request: + paths: + - 'python/**' + - 'scripts/check_wire_contract.py' + - 'crates/engine/src/gateway/**' + - 'crates/cli/src/gateway_cmd.rs' + - '.github/workflows/python-cli.yaml' + push: + branches: [main] + paths: + - 'python/**' + - 'scripts/check_wire_contract.py' + - 'crates/engine/src/gateway/**' + - 'crates/cli/src/gateway_cmd.rs' + - '.github/workflows/python-cli.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + python-cli: + name: lint, type-check, test, wire-contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Create venv with the package and tooling + # ty resolves imports against the active venv, so the package and its + # deps (httpx, typer) must be installed there, alongside ruff/ty/pytest. + run: | + uv venv + uv pip install ./python ruff ty pytest httpx + + - name: Ruff format check + run: uv run ruff format --check python/ scripts/check_wire_contract.py + + - name: Ruff lint + run: uv run ruff check python/ scripts/check_wire_contract.py + + - name: Type check (ty) + run: uv run ty check python/sealg scripts/check_wire_contract.py + + - name: Tests + run: uv run pytest python/tests -q + + - name: Wire contract (Python <-> Rust clients agree) + run: python3 scripts/check_wire_contract.py diff --git a/prek.toml b/prek.toml index 05c0843..011c752 100644 --- a/prek.toml +++ b/prek.toml @@ -48,3 +48,17 @@ name = "fail if any source folder exceeds the file-count error threshold" language = "system" entry = "scripts/check_folder_sizes.sh" files = "\\.(tsx|ts|rs)$" + +# ── sealg wire contract: Python client (python/) ↔ Rust client (crates/) ── +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "wire-contract" +name = "sealg wire contract: Python <-> Rust clients agree" +language = "system" +entry = "python3 scripts/check_wire_contract.py" +pass_filenames = false +# Run when the Python contract, the Rust gateway source, or the check script +# changes. Both clients live in this repo, so the check always runs both legs. +files = "^(python/sealg/(contract|client)\\.py|crates/engine/src/gateway/(config|client)\\.rs|crates/cli/src/gateway_cmd\\.rs|scripts/check_wire_contract\\.py)$" diff --git a/python/.env.example b/python/.env.example new file mode 100644 index 0000000..7f04408 --- /dev/null +++ b/python/.env.example @@ -0,0 +1,24 @@ +# SealGate gateway coordinates. Export these in your shell (or your runtime sets +# them). All config is read from the environment only. + +# Gateway origin (no /mcp suffix). Defaults to http://localhost:3000 when unset. +SEALGATE_URL=https://mcp.sealgate.ai + +# SealGate API key from https://dashboard.sealgate.ai. Optional: leave unset when +# an upstream proxy injects Authorization. When set, it is embedded in the +# /mcp/{key}/ path. +# SEALGATE_API_KEY=ew_live_... + +# Zero-knowledge secret key, only needed for tools that decrypt stored secrets. +# Generate it at https://dashboard.sealgate.ai/dashboard/settings. +# SEALGATE_SECRET_KEY=... + +# Stable conversation id for audit/trifecta continuity. Falls back to +# CENTAUR_THREAD_KEY, which some agent runtimes set automatically. +# SEALGATE_CONVERSATION_ID=... + +# CA bundle for a MITM egress proxy (first of these that is set wins), so sealg +# trusts the proxy's CA. +# SSL_CERT_FILE=/etc/ssl/proxy-ca.pem +# REQUESTS_CA_BUNDLE=/etc/ssl/proxy-ca.pem +# NODE_EXTRA_CA_CERTS=/etc/ssl/proxy-ca.pem diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..3efbdda --- /dev/null +++ b/python/README.md @@ -0,0 +1,41 @@ +# sealg (Python) + +A Python client for the SealGate gateway, exposing the same `sealg` CLI as the +Rust binary in this repo. It is a thin MCP-over-HTTP client: `sealg list` / +`sealg call` forward `tools/list` / `tools/call` to your per-user gateway +endpoint, where all policy and enforcement live. It carries no policy of its own. + +It exists alongside the Rust `sealg` because a `uvx`-installable Python package +drops into environments that reach for `uvx`/`pip` rather than a native binary. +The two clients are kept from drifting by `scripts/check_wire_contract.py` (a +pre-commit + CI check): every shared wire constant lives once in +`sealg/contract.py` and is checked against the Rust source in `crates/`. + +## Install and run + +``` +uvx --from python/ sealg doctor # from a checkout +uvx --from 'git+https://github.com/Edison-Watch/cli#subdirectory=python' sealg list +``` + +Or `pip install ./python` into a virtualenv, then run `sealg`. + +## Commands + +``` +sealg doctor # resolved gateway env + reachability probe +sealg list [--json] # tools your org has authorized +sealg call [--args '{}'] # invoke one tool +``` + +Exit codes mirror the Rust CLI: `0` ok, `1` client/transport error, `6` the +gateway returned an MCP tool error (`isError: true`). + +## Configuration + +All from the environment (see `.env.example`): `SEALGATE_URL`, +`SEALGATE_API_KEY` (optional - keyless when auth is injected upstream by a +proxy), `SEALGATE_SECRET_KEY`, `SEALGATE_CONVERSATION_ID` (with a +`CENTAUR_THREAD_KEY` fallback that some agent runtimes set automatically), and a +CA bundle via `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` for +a MITM egress proxy. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..71aadcc --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "sealg" +description = "Python client for the SealGate gateway - governed access to every tool via one CLI" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27.0", + "typer>=0.12.0", +] + +# The command this package installs: `sealg list`, `sealg call ...`, `sealg doctor`. +[project.scripts] +sealg = "sealg.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["sealg"] diff --git a/python/sealg/__init__.py b/python/sealg/__init__.py new file mode 100644 index 0000000..96b70e3 --- /dev/null +++ b/python/sealg/__init__.py @@ -0,0 +1 @@ +"""SealGate CLI (sealg) - the Python client for the SealGate gateway.""" diff --git a/python/sealg/cli.py b/python/sealg/cli.py new file mode 100644 index 0000000..bee07a4 --- /dev/null +++ b/python/sealg/cli.py @@ -0,0 +1,145 @@ +"""CLI for SealGate - a thin MCP client to the SealGate gateway. + +``sealg list`` / ``sealg call`` forward ``tools/list`` / ``tools/call`` to the +per-user gateway endpoint; all policy and enforcement live in the gateway. +``sealg doctor`` reports the resolved environment and probes reachability. This +is the Python client; it mirrors the Rust ``sealg`` binary's surface and exit +codes. +""" + +from __future__ import annotations + +import json +import os +import platform + +import typer + +from .client import GatewayClient, GatewayConfig, redact_url +from .contract import ENV_CA_BUNDLE, EXIT_ERROR, EXIT_TOOL_ERROR + +app = typer.Typer( + name="sealg", + help="Command-line interface for SealGate, the agentic data firewall", + no_args_is_help=True, + add_completion=False, +) + + +def _emit(value: object) -> None: + print(json.dumps(value, indent=2, ensure_ascii=False, default=str)) + + +@app.command("doctor") +def doctor( + gateway_url: str = typer.Option( + None, "--gateway-url", help="Override the gateway base URL." + ), +) -> None: + """Report the resolved gateway environment and probe reachability.""" + cfg = GatewayConfig.from_env(url_override=gateway_url) + report: dict[str, object] = { + "tool": "sealg", + "os": platform.system().lower(), + "arch": platform.machine(), + "gateway_url": redact_url(cfg.mcp_url(), cfg.api_key), + "auth": cfg.auth_mode(), + "secret_key_set": cfg.secret_key is not None, + "conversation_id_set": cfg.conversation_id is not None, + "ca_bundle": cfg.ca_bundle, + # Which env var actually supplied the bundle (first non-blank, matching + # GatewayConfig's precedence), or None. + "ca_bundle_source": next( + (k for k in ENV_CA_BUNDLE if os.environ.get(k, "").strip()), None + ), + } + try: + client = GatewayClient(cfg).connect() + try: + report["reachable"] = True + report["tool_count"] = len(client.tools_list()) + finally: + client.close() + except Exception as exc: # noqa: BLE001 - doctor reports failures, never raises on them + report["reachable"] = False + report["error"] = str(exc) + + _emit(report) # doctor is a diagnostic; always structured JSON + if not report["reachable"]: + raise typer.Exit(EXIT_ERROR) + + +@app.command("list") +def list_tools( + json_out: bool = typer.Option( + False, "--json", help="Output as a JSON array of {name, description}." + ), + gateway_url: str = typer.Option( + None, "--gateway-url", help="Override the gateway base URL." + ), +) -> None: + """List the user's tools from the live SealGate gateway.""" + cfg = GatewayConfig.from_env(url_override=gateway_url) + try: + client = GatewayClient(cfg).connect() + try: + tools = client.tools_list() + finally: + client.close() + except Exception as exc: # noqa: BLE001 + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(EXIT_ERROR) from exc + + if json_out: + _emit([{"name": t.name, "description": t.description} for t in tools]) + else: + for t in tools: + # The em dash keeps this list output identical to the Rust client's; + # written as a backslash-u2014 escape because the repo ai-writing + # check bans a literal U+2014 in source. + typer.echo(f"{t.name} \u2014 {t.description}") + + +@app.command("call") +def call_tool( + tool: str = typer.Argument(..., help="Tool name as advertised by `sealg list`."), + args: str = typer.Option( + "{}", "--args", help="JSON arguments object to pass to the tool." + ), + gateway_url: str = typer.Option( + None, "--gateway-url", help="Override the gateway base URL." + ), +) -> None: + """Call a tool on the live SealGate gateway.""" + try: + arguments = json.loads(args) + except json.JSONDecodeError as exc: + typer.echo(f"error: invalid --args JSON: {exc}", err=True) + raise typer.Exit(EXIT_ERROR) from exc + # MCP arguments must be an object. Reject a valid-JSON non-object locally + # (e.g. --args '5' or '"x"') with a clear error rather than forwarding an + # invalid tools/call. null is allowed; the client maps it to {}. + if arguments is not None and not isinstance(arguments, dict): + typer.echo("error: --args must be a JSON object", err=True) + raise typer.Exit(EXIT_ERROR) + + cfg = GatewayConfig.from_env(url_override=gateway_url) + try: + client = GatewayClient(cfg).connect() + try: + result = client.tools_call(tool, arguments) + finally: + client.close() + except Exception as exc: # noqa: BLE001 + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(EXIT_ERROR) from exc + + _emit(result) + # Mirror the MCP tool-call response: an `isError: true` result is a failed + # call and must not exit 0. + if isinstance(result, dict) and result.get("isError") is True: + raise typer.Exit(EXIT_TOOL_ERROR) + + +if __name__ == "__main__": + app() diff --git a/python/sealg/client.py b/python/sealg/client.py new file mode 100644 index 0000000..870e6d5 --- /dev/null +++ b/python/sealg/client.py @@ -0,0 +1,312 @@ +"""A thin MCP-over-HTTP client to the SealGate gateway. + +A faithful Python port of the Rust ``sealg`` transport in this repo +(``crates/engine/src/gateway/``). It speaks the small slice of MCP it needs - +``initialize``, ``tools/list``, ``tools/call`` - directly to the gateway's +``/mcp/{api_key}/`` endpoint. It carries no policy: the gateway enforces access +control, lethal-trifecta blocking, and audit. All the wire constants come from +:mod:`contract` so this client and the Rust one cannot drift (the pre-commit +guard verifies it). + +Config resolves purely from the environment (matching the Rust binary), so the +CLI stays stateless and drops cleanly into any shell, CI job, or agent sandbox. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import ssl +from collections.abc import Mapping +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +import httpx + +from .contract import ( + ACCEPT, + CONVERSATION_ID_HEADER, + DEFAULT_URL, + ENV_API_KEY, + ENV_CA_BUNDLE, + ENV_CONVERSATION_ID, + ENV_CONVERSATION_ID_FALLBACK, + ENV_SECRET_KEY, + ENV_URL, + MCP_PATH_KEYLESS, + MCP_PATH_WITH_KEY, + PROTOCOL_VERSION, + PROTOCOL_VERSION_HEADER, + SECRET_KEY_HEADER, +) + +DEFAULT_TIMEOUT = 30.0 + + +class GatewayError(Exception): + """Any failure reaching or talking to the gateway.""" + + +class RpcError(GatewayError): + """A JSON-RPC error returned by the gateway (mirrors the MCP error shape).""" + + def __init__(self, code: int, message: str, data: object = None) -> None: + super().__init__(f"rpc error {code}: {message}") + self.code = code + self.message = message + self.data = data + + +@dataclass +class ToolInfo: + name: str + description: str + input_schema: object + + +@dataclass +class GatewayConfig: + """Everything needed to reach the gateway, resolved once from the env.""" + + base_url: str + api_key: str | None + secret_key: str | None + conversation_id: str | None + ca_bundle: str | None + + @staticmethod + def _get(env: Mapping[str, str], key: str) -> str | None: + """Return a non-blank env value or None (blank is treated as unset).""" + val = env.get(key) + if val is None: + return None + val = val.strip() + return val or None + + @classmethod + def from_env( + cls, env: Mapping[str, str] | None = None, url_override: str | None = None + ) -> GatewayConfig: + env = os.environ if env is None else env + + base_url = (cls._get(env, ENV_URL) or DEFAULT_URL).rstrip("/") + if url_override is not None: + trimmed = url_override.strip().rstrip("/") + if trimmed: + base_url = trimmed + + conversation_id = cls._get(env, ENV_CONVERSATION_ID) or cls._get( + env, ENV_CONVERSATION_ID_FALLBACK + ) + ca_bundle = next((v for k in ENV_CA_BUNDLE if (v := cls._get(env, k))), None) + + return cls( + base_url=base_url, + api_key=cls._get(env, ENV_API_KEY), + secret_key=cls._get(env, ENV_SECRET_KEY), + conversation_id=conversation_id, + ca_bundle=ca_bundle, + ) + + def mcp_url(self) -> str: + """``{base}/mcp/{key}/`` with a key, else ``{base}/mcp/`` (auth injected). + + The path shapes come from :mod:`contract` (``MCP_PATH_*``) so the drift + guard can compare them against the Rust ``mcp_url``. + """ + if self.api_key: + return self.base_url + MCP_PATH_WITH_KEY.format(key=self.api_key) + return self.base_url + MCP_PATH_KEYLESS + + def auth_mode(self) -> str: + return "env-key" if self.api_key else "proxy-injected" + + +def redact_url(url: str, api_key: str | None) -> str: + """Replace the ``/{key}/`` path segment with ``/***/`` so errors don't leak + the key (it rides in the ``/mcp/{key}/`` path). Only the delimited segment + is replaced, never a blanket substring swap.""" + if api_key: + return url.replace(f"/{api_key}/", "/***/") + return url + + +def _extract_rpc_result(content_type: str, body: str, want_id: int) -> Any: + """Extract the JSON-RPC ``result`` for ``want_id`` from a JSON or SSE body. + + Raises :class:`RpcError` on a JSON-RPC error, :class:`GatewayError` on a + protocol problem. Pure, so it is unit-tested directly. + """ + if "text/event-stream" in content_type: + msg = _sse_find_response(body, want_id) + if msg is None: + raise GatewayError("no JSON-RPC response in SSE stream") + else: + try: + msg = json.loads(body.strip()) + except json.JSONDecodeError as e: + raise GatewayError(f"invalid JSON response: {e}") from e + return _rpc_message_to_result(msg) + + +def _rpc_message_to_result(msg: dict) -> Any: + if "error" in msg and msg["error"] is not None: + err = msg["error"] + raise RpcError( + code=int(err.get("code", 0)), + message=str(err.get("message", "unknown error")), + data=err.get("data"), + ) + if "result" in msg: + return msg["result"] + raise GatewayError("JSON-RPC response had neither result nor error") + + +def _sse_find_response(body: str, want_id: int) -> dict | None: + """Scan SSE ``data:`` frames for the JSON-RPC response matching ``want_id``.""" + fallback: dict | None = None + for line in body.splitlines(): + line = line.lstrip() + if not line.startswith("data:"): + continue + try: + v = json.loads(line[len("data:") :].strip()) + except json.JSONDecodeError: + continue + if not isinstance(v, dict) or ("result" not in v and "error" not in v): + continue # notifications carry neither + if v.get("id") == want_id: + return v + if fallback is None: + fallback = v + return fallback + + +class GatewayClient: + """A live connection to the gateway's per-user MCP endpoint.""" + + def __init__(self, cfg: GatewayConfig, timeout: float = DEFAULT_TIMEOUT) -> None: + self.cfg = cfg + self.url = cfg.mcp_url() + self._session_id: str | None = None + self._next_id = 0 + # verify: add the MITM CA to the system trust store (additive, matching + # the Rust client's add_root_certificate) rather than replacing it. + verify: ssl.SSLContext | bool = True + if cfg.ca_bundle: + ctx = ssl.create_default_context() + ctx.load_verify_locations(cafile=cfg.ca_bundle) + verify = ctx + # trust_env=True (default) honors HTTPS_PROXY/HTTP_PROXY. + # follow_redirects mirrors reqwest's default (the Rust client follows up + # to 10); the gateway's trailing-slash /mcp/{key}/ path can 307-normalize. + self._http = httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) + + def __enter__(self) -> GatewayClient: + self.connect() + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + self._http.close() + + def connect(self) -> GatewayClient: + """Run the MCP ``initialize`` handshake and capture any session id.""" + params = { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "sealg", "version": _version()}, + } + _, session_id = self._rpc_capture_session("initialize", params) + self._session_id = session_id + # Best-effort readiness notification; stateless servers may ignore it. + with contextlib.suppress(GatewayError): + self._notify("notifications/initialized", {}) + return self + + def tools_list(self) -> list[ToolInfo]: + result = self._rpc("tools/list", {}) + tools = result.get("tools") if isinstance(result, dict) else None + if not isinstance(tools, list): + raise GatewayError("tools/list missing `tools` array") + return [ + ToolInfo( + name=t.get("name", ""), + description=t.get("description", ""), + input_schema=t.get("inputSchema"), + ) + for t in tools + ] + + def tools_call(self, name: str, arguments: object) -> Any: + # Mirror the Rust client: only a null/None arguments becomes {}. + args = {} if arguments is None else arguments + return self._rpc("tools/call", {"name": name, "arguments": args}) + + # --- transport --------------------------------------------------------- + + def _bump_id(self) -> int: + self._next_id += 1 + return self._next_id + + def _headers(self) -> dict[str, str]: + h = { + "Content-Type": "application/json", + "Accept": ACCEPT, + PROTOCOL_VERSION_HEADER: PROTOCOL_VERSION, + } + if self._session_id: + h["Mcp-Session-Id"] = self._session_id + if self.cfg.secret_key: + h[SECRET_KEY_HEADER] = self.cfg.secret_key + if self.cfg.conversation_id: + h[CONVERSATION_ID_HEADER] = self.cfg.conversation_id + return h + + def _rpc(self, method: str, params: object) -> Any: + return self._rpc_capture_session(method, params)[0] + + def _rpc_capture_session( + self, method: str, params: object + ) -> tuple[Any, str | None]: + rpc_id = self._bump_id() + body = {"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params} + try: + resp = self._http.post(self.url, headers=self._headers(), json=body) + except httpx.TimeoutException as e: + raise GatewayError("timeout") from e + except httpx.HTTPError as e: + raise GatewayError( + f"POST {redact_url(self.url, self.cfg.api_key)}: {e}" + ) from e + + session_id = resp.headers.get("mcp-session-id") + # Mirror reqwest's is_success(): only 2xx carries a JSON-RPC envelope. + # Anything else (an un-followed 3xx after redirect exhaustion, an auth + # 401) is an HTTP-level failure, not a body for the JSON-RPC parser. + if not (200 <= resp.status_code < 300): + raise GatewayError( + f"gateway returned HTTP {resp.status_code}: {resp.text[:512]}" + ) + result = _extract_rpc_result( + resp.headers.get("content-type", ""), resp.text, rpc_id + ) + return result, session_id + + def _notify(self, method: str, params: object) -> None: + body = {"jsonrpc": "2.0", "method": method, "params": params} + try: + self._http.post(self.url, headers=self._headers(), json=body) + except httpx.HTTPError as e: + raise GatewayError(str(e)) from e + + +def _version() -> str: + try: + return version("sealg") + except PackageNotFoundError: + return "0.0.0" diff --git a/python/sealg/contract.py b/python/sealg/contract.py new file mode 100644 index 0000000..c607ed3 --- /dev/null +++ b/python/sealg/contract.py @@ -0,0 +1,63 @@ +"""Single source of truth for the SealGate gateway wire contract. + +This Python client and the Rust ``sealg`` binary in this repo talk to the same +MCP-over-HTTP gateway endpoint, so everything the two must agree on lives here +as plain literals. The drift guard (``scripts/check_wire_contract.py``, run on +pre-commit and in CI) loads this module in isolation and compares it against +the Rust source of truth in ``crates/engine/src/gateway/config.rs`` and +``.../client.rs``. + +Keep this module import-free and side-effect-free: the guard imports it +directly from its file path, so a stray relative import would break the check. +Do not edit a value here without changing the Rust constant it mirrors (or the +guard fails), and vice versa. +""" + +from __future__ import annotations + +# --- protocol (mirrors client.rs::PROTOCOL_VERSION) ------------------------ +# The MCP Streamable HTTP protocol version advertised in ``initialize``. Must be +# a version the gateway's MCP server accepts; it rejects unknown versions with +# JSON-RPC -32600. +PROTOCOL_VERSION = "2025-06-18" + +# --- headers (mirror config.rs SECRET_KEY_HEADER / CONVERSATION_ID_HEADER) -- +# Carries the zero-knowledge secret key value. +SECRET_KEY_HEADER = "sealgate_secret_key" +# Carries the stable conversation id (gateway: SEALGATE_CONVERSATION_ID_HEADER +# in src/middleware/session_tokens.py). +CONVERSATION_ID_HEADER = "x-sealgate-conversation-id" +# Advertises the protocol version on every request. +PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" +# The Accept value that lets the gateway answer with either a single JSON +# object or an SSE stream. +ACCEPT = "application/json, text/event-stream" + +# --- environment keys (mirror config.rs::env_keys) ------------------------- +ENV_URL = "SEALGATE_URL" +ENV_API_KEY = "SEALGATE_API_KEY" +ENV_SECRET_KEY = "SEALGATE_SECRET_KEY" +ENV_CONVERSATION_ID = "SEALGATE_CONVERSATION_ID" +# Fallback conversation-id source, set automatically by some agent runtimes. +# The Rust client honors it too; kept here for wire parity. +ENV_CONVERSATION_ID_FALLBACK = "CENTAUR_THREAD_KEY" +# CA bundle paths for a MITM egress proxy, tried in order (first set wins). +ENV_CA_BUNDLE = ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") + +# --- defaults & path shape (mirror config.rs::DEFAULT_URL / mcp_url) -------- +# Default gateway origin when SEALGATE_URL is unset: the dev endpoint, never a +# guessed prod host. Prod deployments set the env var. +DEFAULT_URL = "http://localhost:3000" +# The MCP endpoint path. With a key it rides in the path; keyless when auth is +# injected upstream by a proxy. +MCP_PATH_WITH_KEY = "/mcp/{key}/" +MCP_PATH_KEYLESS = "/mcp/" + +# --- exit codes (mirror gateway_cmd.rs) ------------------------------------ +# Client/transport failure (bad config, network, protocol). A normal result is +# the framework default (0) and needs no constant. +EXIT_ERROR = 1 +# The gateway returned an MCP tool error (``isError: true``); kept distinct +# from EXIT_ERROR so a caller can tell a failed tool call from a connection +# failure. +EXIT_TOOL_ERROR = 6 diff --git a/python/tests/test_wire.py b/python/tests/test_wire.py new file mode 100644 index 0000000..41df513 --- /dev/null +++ b/python/tests/test_wire.py @@ -0,0 +1,187 @@ +"""Unit tests for the SealGate Python client's wire behavior. + +Ported from the Rust client's tests (``crates/engine/src/gateway/config.rs`` and +``client.rs``) so the two implementations behave identically on the parts that +matter: config resolution, key-in-path, conversation-id fallback, CA-bundle +precedence, JSON/SSE result extraction, redirect/status handling, and URL +redaction. The wire *constants* are covered separately by +``scripts/check_wire_contract.py``; these cover the *logic*. + +Run: ``uv run --with httpx --with pytest pytest python/tests``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import httpx +import pytest + +# Import the package straight from the source dir (no install needed). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sealg import client # noqa: E402 +from sealg.client import GatewayConfig, GatewayError, RpcError, redact_url # noqa: E402 + +_extract = client._extract_rpc_result + + +# --- config resolution (ports config.rs tests) ----------------------------- + + +def test_defaults_when_unset(): + c = GatewayConfig.from_env(env={}) + assert c.base_url == "http://localhost:3000" + assert c.api_key is None + assert c.mcp_url() == "http://localhost:3000/mcp/" + assert c.auth_mode() == "proxy-injected" + + +def test_key_goes_in_the_path_and_trailing_slash_is_trimmed(): + c = GatewayConfig.from_env( + env={ + "SEALGATE_URL": "https://dashboard.sealgate.ai/", + "SEALGATE_API_KEY": "ew_live_abc", + } + ) + assert c.base_url == "https://dashboard.sealgate.ai" + assert c.mcp_url() == "https://dashboard.sealgate.ai/mcp/ew_live_abc/" + assert c.auth_mode() == "env-key" + + +def test_conversation_id_falls_back(): + c = GatewayConfig.from_env(env={"CENTAUR_THREAD_KEY": "slack:C123:171.45"}) + assert c.conversation_id == "slack:C123:171.45" + c = GatewayConfig.from_env( + env={"SEALGATE_CONVERSATION_ID": "explicit", "CENTAUR_THREAD_KEY": "fallback"} + ) + assert c.conversation_id == "explicit" + + +def test_ca_bundle_tries_paths_in_order(): + c = GatewayConfig.from_env(env={"REQUESTS_CA_BUNDLE": "/certs/ca.pem"}) + assert c.ca_bundle == "/certs/ca.pem" + c = GatewayConfig.from_env( + env={"SSL_CERT_FILE": "/a.pem", "REQUESTS_CA_BUNDLE": "/b.pem"} + ) + assert c.ca_bundle == "/a.pem" + + +def test_url_override_trims_and_ignores_blank(): + c = GatewayConfig.from_env(env={}, url_override="https://gw.example.com/") + assert c.base_url == "https://gw.example.com" + for degenerate in (" ", "/", "///", " // "): + c = GatewayConfig.from_env( + env={"SEALGATE_URL": "https://keep.example"}, url_override=degenerate + ) + assert c.base_url == "https://keep.example" + + +def test_blank_values_treated_as_unset(): + c = GatewayConfig.from_env(env={"SEALGATE_URL": " ", "SEALGATE_API_KEY": ""}) + assert c.base_url == "http://localhost:3000" + assert c.api_key is None + + +# --- result extraction (ports client.rs tests) ----------------------------- + + +def test_plain_json_result(): + r = _extract( + "application/json", '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', 1 + ) + assert r["tools"] == [] + + +def test_json_rpc_error_maps_to_rpc_error(): + body = '{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"blocked by policy","data":{"rule":"trifecta"}}}' + with pytest.raises(RpcError) as ei: + _extract("application/json", body, 2) + assert ei.value.code == -32000 + assert ei.value.message == "blocked by policy" + assert ei.value.data == {"rule": "trifecta"} + + +def test_sse_stream_picks_matching_id(): + body = 'event: message\ndata: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}\n\n' + assert _extract("text/event-stream; charset=utf-8", body, 7)["ok"] is True + + +def test_sse_skips_notifications_and_finds_response(): + body = ( + 'data: {"jsonrpc":"2.0","method":"notifications/message","params":{}}\n' + 'data: {"jsonrpc":"2.0","id":3,"result":{"done":1}}\n' + ) + assert _extract("text/event-stream", body, 3)["done"] == 1 + + +def test_invalid_json_is_protocol_error(): + with pytest.raises(GatewayError): + _extract("application/json", "not json", 1) + + +# --- redaction (ports client.rs redact test) ------------------------------- + + +def test_redact_url_hides_the_api_key(): + out = redact_url("https://gw.example/mcp/ew_live_SECRET/", "ew_live_SECRET") + assert "ew_live_SECRET" not in out + assert out == "https://gw.example/mcp/***/" + assert redact_url("https://gw.example/mcp/", None) == "https://gw.example/mcp/" + assert ( + redact_url("https://abc.example/mcp/abc/", "abc") + == "https://abc.example/mcp/***/" + ) + + +# --- HTTP status / redirect handling (match reqwest) ----------------------- + + +def _mock_client(handler): + cfg = GatewayConfig.from_env( + env={"SEALGATE_URL": "https://gw.test", "SEALGATE_API_KEY": "k"} + ) + gc = client.GatewayClient(cfg) + gc._http = httpx.Client( + transport=httpx.MockTransport(handler), follow_redirects=True + ) + return gc + + +def test_client_follows_redirects_by_default(): + cfg = GatewayConfig.from_env(env={"SEALGATE_URL": "https://gw.test"}) + gc = client.GatewayClient(cfg) + try: + assert gc._http.follow_redirects is True + finally: + gc.close() + + +def test_non_2xx_is_http_error_not_parse_error(): + gc = _mock_client(lambda request: httpx.Response(500, text="boom")) + try: + with pytest.raises(GatewayError) as ei: + gc._rpc("tools/list", {}) + assert "HTTP 500" in str(ei.value) + finally: + gc.close() + + +def test_3xx_is_followed_to_the_result(): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/mcp/k/": + return httpx.Response(307, headers={"location": "https://gw.test/mcp/k2/"}) + return httpx.Response( + 200, json={"jsonrpc": "2.0", "id": 1, "result": {"tools": []}} + ) + + gc = _mock_client(handler) + try: + assert gc._rpc("tools/list", {}) == {"tools": []} + finally: + gc.close() + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/scripts/check_wire_contract.py b/scripts/check_wire_contract.py new file mode 100644 index 0000000..080e51e --- /dev/null +++ b/scripts/check_wire_contract.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +"""Fail if the SealGate wire contract drifts between the Python and Rust clients. + +This repo ships two ``sealg`` clients for the same MCP-over-HTTP gateway endpoint: + +1. The Rust binary (the canonical source of truth): ``crates/engine/src/gateway/ + config.rs``, ``.../client.rs``, and ``crates/cli/src/gateway_cmd.rs``. +2. The Python client in ``python/sealg/``, whose wire constants live in + ``contract.py``. + +If the two disagree about the protocol version, header names, env-var names, the +``/mcp/{key}/`` path shape, or the tool-error exit code, one client silently +talks to the gateway differently from the other. Because both live in this repo, +this guard reads both directly and always compares them - no cross-repo checkout. + +Run: ``python3 scripts/check_wire_contract.py`` (also the ``wire-contract`` prek +hook and the python-cli CI workflow). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONTRACT_PATH = REPO_ROOT / "python" / "sealg" / "contract.py" +CONFIG_RS = REPO_ROOT / "crates" / "engine" / "src" / "gateway" / "config.rs" +CLIENT_RS = REPO_ROOT / "crates" / "engine" / "src" / "gateway" / "client.rs" +GATEWAY_CMD_RS = REPO_ROOT / "crates" / "cli" / "src" / "gateway_cmd.rs" + +# The canonical values, asserted against both clients. Editing a value here means +# the wire changed: update contract.py AND the Rust constant to match. +SNAPSHOT = { + "PROTOCOL_VERSION": "2025-06-18", + "SECRET_KEY_HEADER": "sealgate_secret_key", + "CONVERSATION_ID_HEADER": "x-sealgate-conversation-id", + "ACCEPT": "application/json, text/event-stream", + "PROTOCOL_VERSION_HEADER": "MCP-Protocol-Version", + "ENV_URL": "SEALGATE_URL", + "ENV_API_KEY": "SEALGATE_API_KEY", + "ENV_SECRET_KEY": "SEALGATE_SECRET_KEY", + "ENV_CONVERSATION_ID": "SEALGATE_CONVERSATION_ID", + "ENV_CONVERSATION_ID_FALLBACK": "CENTAUR_THREAD_KEY", + "ENV_CA_BUNDLE": ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"), + "DEFAULT_URL": "http://localhost:3000", + "MCP_PATH_WITH_KEY": "/mcp/{key}/", + "MCP_PATH_KEYLESS": "/mcp/", + "EXIT_TOOL_ERROR": 6, +} + + +def _fail(lines: list[str]) -> None: + print("sealg wire-contract drift check FAILED:\n", file=sys.stderr) + for line in lines: + print(f" - {line}", file=sys.stderr) + print( + "\nThe Python client (python/sealg/contract.py) and the Rust client " + "(crates/engine/src/gateway/) must agree. Update both, or fix the snapshot " + "in this script if the wire genuinely changed.", + file=sys.stderr, + ) + sys.exit(1) + + +def _load_contract() -> dict[str, object]: + # Exec the source text directly rather than importing it: SourceFileLoader + # caches bytecode in __pycache__ keyed on 1-second mtime granularity, so two + # edits within the same second can serve a stale .pyc and hide real drift. + if not CONTRACT_PATH.is_file(): + _fail([f"cannot find {CONTRACT_PATH}"]) + namespace: dict[str, object] = {} + exec(compile(CONTRACT_PATH.read_text(), str(CONTRACT_PATH), "exec"), namespace) # noqa: S102 + missing = [k for k in SNAPSHOT if k not in namespace] + if missing: + _fail([f"contract.py is missing constant {k}" for k in missing]) + return {k: namespace[k] for k in SNAPSHOT} + + +def _check_python(contract: dict[str, object], problems: list[str]) -> None: + for key, want in SNAPSHOT.items(): + got = contract.get(key) + if got != want: + problems.append(f"contract.py {key} = {got!r}, expected {want!r}") + + +def _rust_str_const(text: str, name: str) -> str | None: + m = re.search(rf'const\s+{re.escape(name)}\s*:\s*&str\s*=\s*"([^"]*)"', text) + return m.group(1) if m else None + + +def _rust_str_list(text: str, name: str) -> tuple[str, ...] | None: + m = re.search( + rf"const\s+{re.escape(name)}\s*:\s*&\[&str\]\s*=\s*&\[([^\]]*)\]", text + ) + if not m: + return None + return tuple(re.findall(r'"([^"]*)"', m.group(1))) + + +def _rust_int_const(text: str, name: str) -> int | None: + m = re.search(rf"const\s+{re.escape(name)}\s*:\s*i32\s*=\s*(\d+)", text) + return int(m.group(1)) if m else None + + +def _rust_mcp_path_shapes(text: str) -> tuple[str | None, str | None]: + """Extract the with-key and keyless path shapes from config.rs `mcp_url`. + + Rust builds them as ``format!("{}/mcp/{}/", base, key)`` and + ``format!("{}/mcp/", base)``. Strip the leading ``{}`` (base_url) and + normalize the key placeholder ``{}`` -> ``{key}`` so the shapes compare to + the Python constants. The two clients hitting different URLs is the worst + drift, so it gets an explicit extractor. + """ + with_key = keyless = None + for fmt in re.findall(r'format!\(\s*"([^"]*)"', text): + if not fmt.startswith("{}/mcp/"): + continue + suffix = fmt[len("{}") :] + if "{}" in suffix: + with_key = suffix.replace("{}", "{key}") + else: + keyless = suffix + return with_key, keyless + + +def _check_rust(problems: list[str]) -> None: + for path in (CONFIG_RS, CLIENT_RS, GATEWAY_CMD_RS): + if not path.is_file(): + _fail([f"cannot find Rust source {path} (repo layout moved)"]) + config = CONFIG_RS.read_text() + client = CLIENT_RS.read_text() + gateway_cmd = GATEWAY_CMD_RS.read_text() + + mcp_with_key, mcp_keyless = _rust_mcp_path_shapes(config) + + # (snapshot key, value parsed from Rust, human name of the Rust symbol) + checks: list[tuple[str, object, str]] = [ + ( + "PROTOCOL_VERSION", + _rust_str_const(client, "PROTOCOL_VERSION"), + "PROTOCOL_VERSION", + ), + ( + "SECRET_KEY_HEADER", + _rust_str_const(config, "SECRET_KEY_HEADER"), + "SECRET_KEY_HEADER", + ), + ( + "CONVERSATION_ID_HEADER", + _rust_str_const(config, "CONVERSATION_ID_HEADER"), + "CONVERSATION_ID_HEADER", + ), + ("DEFAULT_URL", _rust_str_const(config, "DEFAULT_URL"), "DEFAULT_URL"), + ("ENV_URL", _rust_str_const(config, "URL"), "env_keys::URL"), + ("ENV_API_KEY", _rust_str_const(config, "API_KEY"), "env_keys::API_KEY"), + ( + "ENV_SECRET_KEY", + _rust_str_const(config, "SECRET_KEY"), + "env_keys::SECRET_KEY", + ), + ( + "ENV_CONVERSATION_ID", + _rust_str_const(config, "CONVERSATION_ID"), + "env_keys::CONVERSATION_ID", + ), + ( + "ENV_CONVERSATION_ID_FALLBACK", + _rust_str_const(config, "CENTAUR_THREAD_KEY"), + "env_keys::CENTAUR_THREAD_KEY", + ), + ("ENV_CA_BUNDLE", _rust_str_list(config, "CA_BUNDLE"), "env_keys::CA_BUNDLE"), + ("MCP_PATH_WITH_KEY", mcp_with_key, "mcp_url (with-key format!)"), + ("MCP_PATH_KEYLESS", mcp_keyless, "mcp_url (keyless format!)"), + ( + "EXIT_TOOL_ERROR", + _rust_int_const(gateway_cmd, "EXIT_TOOL_ERROR"), + "EXIT_TOOL_ERROR", + ), + ] + for snap_key, got, rust_name in checks: + want = SNAPSHOT[snap_key] + if got is None: + problems.append(f"could not find Rust {rust_name} (parser or source moved)") + elif got != want: + problems.append( + f"Rust {rust_name} = {got!r}, expected {want!r} (contract.py {snap_key})" + ) + + # The Accept + protocol-version headers are string literals in client.rs's + # request builder. Anchor to the `.header(...)` call so a stale copy of the + # literal elsewhere (a comment, a test) can't satisfy the check. + accept = re.escape(str(SNAPSHOT["ACCEPT"])) + ver_header = re.escape(str(SNAPSHOT["PROTOCOL_VERSION_HEADER"])) + header_checks = [ + ("ACCEPT", rf'\.header\(\s*[^,]*ACCEPT\s*,\s*"{accept}"'), + ("PROTOCOL_VERSION_HEADER", rf'\.header\(\s*"{ver_header}"\s*,'), + ] + for snap_key, pattern in header_checks: + if not re.search(pattern, client): + problems.append( + f"Rust client.rs request builder no longer sets {snap_key} = " + f"{SNAPSHOT[snap_key]!r} (parser or request builder moved)" + ) + + +def main() -> None: + problems: list[str] = [] + contract = _load_contract() + _check_python(contract, problems) + _check_rust(problems) + if problems: + _fail(problems) + print(f"sealg wire-contract OK: {len(SNAPSHOT)} constants agree [Python + Rust]") + + +if __name__ == "__main__": + main() From 9ab0309af6bee0756595445908a7b7ed7f556210 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:09:14 +0000 Subject: [PATCH 2/3] fix(python): satisfy ruff 0.16.5 defaults; pin ruff/ty in CI CI installs the latest ruff, whose defaults are stricter than the local version used to author the package: - return Self from GatewayClient.__enter__ (PYI034) - drop the two unused `# noqa: BLE001` on the re-raising except blocks; keep it only on doctor's deliberate catch-all - drop the two unused `# noqa: E402` in the test - drop the orphan shebang from check_wire_contract.py (run via `python3`) Pin ruff==0.16.5 and ty==0.0.77 in the workflow so local and CI agree. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- .github/workflows/python-cli.yaml | 2 +- .../sealg/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 228 bytes python/sealg/__pycache__/client.cpython-311.pyc | Bin 0 -> 17700 bytes .../sealg/__pycache__/contract.cpython-311.pyc | Bin 0 -> 1745 bytes python/sealg/cli.py | 4 ++-- python/sealg/client.py | 4 ++-- .../test_wire.cpython-311-pytest-9.1.1.pyc | Bin 0 -> 30359 bytes python/tests/test_wire.py | 4 ++-- scripts/check_wire_contract.py | 1 - 9 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 python/sealg/__pycache__/__init__.cpython-311.pyc create mode 100644 python/sealg/__pycache__/client.cpython-311.pyc create mode 100644 python/sealg/__pycache__/contract.cpython-311.pyc create mode 100644 python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc diff --git a/.github/workflows/python-cli.yaml b/.github/workflows/python-cli.yaml index 4409b96..5c861b3 100644 --- a/.github/workflows/python-cli.yaml +++ b/.github/workflows/python-cli.yaml @@ -38,7 +38,7 @@ jobs: # deps (httpx, typer) must be installed there, alongside ruff/ty/pytest. run: | uv venv - uv pip install ./python ruff ty pytest httpx + uv pip install ./python 'ruff==0.16.5' 'ty==0.0.77' pytest httpx - name: Ruff format check run: uv run ruff format --check python/ scripts/check_wire_contract.py diff --git a/python/sealg/__pycache__/__init__.cpython-311.pyc b/python/sealg/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07f5ede743ecb47699162691b542af19a9a96fdc GIT binary patch literal 228 zcmZ3^%ge<81S#4xvOIzGV-N=h7@>^MASKfoQW&BbQW%37G?}WLf>RT7+!ISu6`XxM z6*P*0ymU}zQ z`1q9!pFvjrQrFMO&rQ`YElw@c2kO@^02`$bHeNqIJ~J<~BtBlRpz;@oO>TZlX-=wL d5gSk+$eqQ4K;i>4BO~Jt29FCcRKx-l1psv?K3xC+ literal 0 HcmV?d00001 diff --git a/python/sealg/__pycache__/client.cpython-311.pyc b/python/sealg/__pycache__/client.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..345506b9bb25533d46c21eca81cb173ed15eba35 GIT binary patch literal 17700 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 literal 0 HcmV?d00001 diff --git a/python/sealg/__pycache__/contract.cpython-311.pyc b/python/sealg/__pycache__/contract.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cde917eef4cda9226798dc5566c8481600dec2c4 GIT binary patch literal 1745 zcmZuxL2uhO6qb`VN#!`PaWr@e~n)A zuTj%CY2MG%b$^`}`~uzZH|VCnNsE4wmi!Xk^0#Q&HFmd23SrzZ?newsJ`w>#yoZz+ zsR8P7fz*H@myzfRQ4IBAI3;6r8VZI29xFitRW(g-5K7b;s{xNu5QQvOh{QDWbw*O5 zZdbw){caaM4`U+6D2%}fK7u72DH6Rz3f>S(k|8s{t#>N?go(NthnbdhMZ#>a=4w*VB2dv7 z^#mURAW<)b{1T!DSuF^X&u9v3>+qezqo8v&EYnX!>RahSqL5UB#I40OQoexHnQGxHniV zgE1|3oVM4lx0_hg2GVKieO~wwDBDhY<5E@^_|41C7lxHuy>55Bbk%MhXFBHfq4yjf&I94)bz4gB`7}b)VxFdeFFwvwBiDS zRnvCe)U=;9JZw8o+etUGxz`2}GT;y6El6X$`@|aXA+tu334Lp=B>Q+;S=Z^yJ^!*M z<9BOc&p#*Mh>yWfD=&Y6VYZyhi*C9aZyu71NHI0qUsR@Udx2K!>CvF3;zX7@dHIx7V literal 0 HcmV?d00001 diff --git a/python/sealg/cli.py b/python/sealg/cli.py index bee07a4..57ac6af 100644 --- a/python/sealg/cli.py +++ b/python/sealg/cli.py @@ -86,7 +86,7 @@ def list_tools( tools = client.tools_list() finally: client.close() - except Exception as exc: # noqa: BLE001 + except Exception as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(EXIT_ERROR) from exc @@ -130,7 +130,7 @@ def call_tool( result = client.tools_call(tool, arguments) finally: client.close() - except Exception as exc: # noqa: BLE001 + except Exception as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(EXIT_ERROR) from exc diff --git a/python/sealg/client.py b/python/sealg/client.py index 870e6d5..856a11b 100644 --- a/python/sealg/client.py +++ b/python/sealg/client.py @@ -21,7 +21,7 @@ from collections.abc import Mapping from dataclasses import dataclass from importlib.metadata import PackageNotFoundError, version -from typing import Any +from typing import Any, Self import httpx @@ -204,7 +204,7 @@ def __init__(self, cfg: GatewayConfig, timeout: float = DEFAULT_TIMEOUT) -> None # to 10); the gateway's trailing-slash /mcp/{key}/ path can 307-normalize. self._http = httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) - def __enter__(self) -> GatewayClient: + def __enter__(self) -> Self: self.connect() return self diff --git a/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc b/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b807846638bb72109958185944189663419d3dd GIT binary patch literal 30359 zcmeHwZEzb$n%)dB_y#{jQXjM>8B19-L5l=GWJ#nfd1ZZBvQ62dWM{Q;AP@tIph$q8 z0VN6x?lBiv9lfQ^`ck{MygO|jFLmdWV&9c6zm!yxvMX0tuKNWrV1+^ADy8m6%2oM6 z$9C10{v^-aJ<~G-0yO39u4}gh4qv?8{k+{h(=*d=zx{UOuNoSH5
XK`Zd4oUh) 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 Date: Wed, 2 Sep 2026 14:20:23 +0000 Subject: [PATCH 3/3] fix(python): address cubic review on the sealg CLI - client: strip the credential headers (secret key, conversation id, session id) on any cross-origin redirect, and cap redirects at 10 to match the Rust client (P1: httpx forwards custom headers across origins on redirect). - client: return the raw non-blank env value from _get; strip() is only the blank test, so a padded key/id is sent identically to the Rust client. - client: a valid-JSON scalar response is a GatewayError, not a TypeError. - client: a missing/invalid CA bundle raises GatewayError, not a raw ssl/OSError. - client: redact the API key from a non-2xx HTTP error body before it hits stderr. - cli: doctor distinguishes reachability (connect) from a tools/list probe failure instead of reporting a reachable gateway as unreachable. - guard: add EXIT_ERROR (1) to the snapshot and assert the Rust CLI exits 1 on client failure, so the 0/1/6 parity stays enforced on both sides. - prek: include cli.py in the wire-contract hook trigger. - tests: padded-value parity, cross-origin credential strip, scalar-response. Not changed: JSON-RPC id validation / SSE fallback (mirrors the Rust client's deliberate single-response behavior; tightening it belongs in both clients). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EpwFmgQPfugKFF9zugay6Y --- prek.toml | 2 +- .../sealg/__pycache__/client.cpython-311.pyc | Bin 17700 -> 19375 bytes python/sealg/cli.py | 14 +++-- python/sealg/client.py | 57 +++++++++++++----- .../test_wire.cpython-311-pytest-9.1.1.pyc | Bin 30359 -> 37168 bytes python/tests/test_wire.py | 49 +++++++++++++++ scripts/check_wire_contract.py | 11 ++++ 7 files changed, 114 insertions(+), 19 deletions(-) diff --git a/prek.toml b/prek.toml index 011c752..e7915de 100644 --- a/prek.toml +++ b/prek.toml @@ -61,4 +61,4 @@ entry = "python3 scripts/check_wire_contract.py" pass_filenames = false # Run when the Python contract, the Rust gateway source, or the check script # changes. Both clients live in this repo, so the check always runs both legs. -files = "^(python/sealg/(contract|client)\\.py|crates/engine/src/gateway/(config|client)\\.rs|crates/cli/src/gateway_cmd\\.rs|scripts/check_wire_contract\\.py)$" +files = "^(python/sealg/(contract|client|cli)\\.py|crates/engine/src/gateway/(config|client)\\.rs|crates/cli/src/gateway_cmd\\.rs|scripts/check_wire_contract\\.py)$" diff --git a/python/sealg/__pycache__/client.cpython-311.pyc b/python/sealg/__pycache__/client.cpython-311.pyc index 345506b9bb25533d46c21eca81cb173ed15eba35..9477bbb1cd1420d7a1e0eb49d49c6074aec07920 100644 GIT binary patch delta 3802 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 UC(yU!6^;J+wcLAjl;2YJKNr=$IsgCw delta 2506 zcmZ`*drX_x6~FiM17rLEg9)#MFQGiXB&LCe&wfWS_aZ6O6It}AH<1klly&8 zLQSC^QJsXfG`A_))=6Djg``27IxkbSbes3i9@5N;HrGvnDZ&OMKN?z#7zbM0@>(RVIT$GqKcB``klTsft0f6vijqf8vNHCU!3Mtey_5{aB* zk=W`yB1NQOiZVqSKkx-rAK@X{Cx^lz&oe<^=(MbcM&(goa72~ET9R;rj zE(Fe>o;eL-rz`sWEK69*V%?55109N;D{xZUAG_@OvqaB`G4~>UKK4|xhnAa7BSW~Esh@?v-+KSA|%K6F^09TEDLBpOQG28kKO$6G4$^_;AVkgQV7ZK*V78K3zY z_`12~Zz1Oz_`A90^CikGnV0~X<$Gve z%pPHOpgF9XIe=PN4RZp`WwopTsFl^RVxW0UVI@FqY&$ChYG*sxW}ptXlT`wBG7po1 z=CiF(qV*|zx42t9ChnEyQk!_H=80BDBE-9PftXUS5dzE%CQTS3^pq*f^If_iY0l%G?th<8S=>}=Cw~YN?aKg={=(v@amE5xa5u2MPYbVjChR_9&G6n;h&dXp5iO5`K zrU4ejPn-5geAZPEiG0?n7`jCk1GBt0at#~wdQ ztHvEi{KKAJRnr3Sjy}nhT#l8%Hz5=wToiBa?W062@4ZOp#l+{HX?+{S*(l;@G7q3( zVaxLBWnc*7u8qg^@?pP2l(+OtGm?0(u>#)o{Ma{JZqRlmL@Cjoux52IYs7TQk>$Ql zm>KvrJtHjdob>6FBYx2KO6fRQ^Ed)Vb3;nyoK$@b--8qTcJ+)G53M9d=9%9O_$4^h z*Y|ljK%mUWZT@gbhhp{UXNOh(Em*G<@b9B9On1sS9EQ8c0_Q(Mm=z!IYofmtMeTL_ z<0!x#Q)W1xH^BHwWQ<(ZhWQy(-ax=)Y4|R}4XuooqIp|H+H1`PP)6El>_)qTHh<*W zlFaY9UbLj_P2~5$_jcd=PsjDUhPeK|cSW*nkP0352|$`icEq;X0?*K1pL| z!q|DQ^>CUvO;6MRi`&GBCvF#&nzp6MM%W`7x;BZi6b(Lx& z+O0H>q3{HN;^A9>q^w@=;0XLhQN3P{t-;?x2qN?$d`0}F`xu=S`w#WeMREC1Yh}i7 zCF*1Zd{q8rX?-@#MuO@-o{FUp{f$1lhqkB5XJHv-_YvUFhLks5nwHRr3(H~UxRpK$ i$NmhK70{wI*$8!`l-8%o|HkEv{Qi0^H;7jbZ~YIWR)A6f diff --git a/python/sealg/cli.py b/python/sealg/cli.py index 57ac6af..7288522 100644 --- a/python/sealg/cli.py +++ b/python/sealg/cli.py @@ -53,16 +53,22 @@ def doctor( (k for k in ENV_CA_BUNDLE if os.environ.get(k, "").strip()), None ), } + # Separate "could we reach + initialize the gateway" (reachable) from "did + # the tools/list probe succeed" (readiness), so a connect that succeeds but + # a probe that fails isn't reported as unreachable. try: client = GatewayClient(cfg).connect() + except Exception as exc: # noqa: BLE001 - doctor reports failures, never raises on them + report["reachable"] = False + report["error"] = str(exc) + else: + report["reachable"] = True try: - report["reachable"] = True report["tool_count"] = len(client.tools_list()) + except Exception as exc: # noqa: BLE001 + report["probe_error"] = str(exc) finally: client.close() - except Exception as exc: # noqa: BLE001 - doctor reports failures, never raises on them - report["reachable"] = False - report["error"] = str(exc) _emit(report) # doctor is a diagnostic; always structured JSON if not report["reachable"]: diff --git a/python/sealg/client.py b/python/sealg/client.py index 856a11b..78bc0be 100644 --- a/python/sealg/client.py +++ b/python/sealg/client.py @@ -78,12 +78,14 @@ class GatewayConfig: @staticmethod def _get(env: Mapping[str, str], key: str) -> str | None: - """Return a non-blank env value or None (blank is treated as unset).""" + """Return the raw env value if non-blank, else None. strip() is only the + blank test - the value itself is returned unmodified, matching the Rust + client (`.filter(|v| !v.trim().is_empty())`), so a padded key or id is + sent identically by both clients.""" val = env.get(key) - if val is None: + if val is None or not val.strip(): return None - val = val.strip() - return val or None + return val @classmethod def from_env( @@ -151,9 +153,14 @@ def _extract_rpc_result(content_type: str, body: str, want_id: int) -> Any: return _rpc_message_to_result(msg) -def _rpc_message_to_result(msg: dict) -> Any: +def _rpc_message_to_result(msg: object) -> Any: + # A valid-JSON scalar (e.g. `5`) is a protocol error, not a crash. + if not isinstance(msg, dict): + raise GatewayError("JSON-RPC response was not an object") if "error" in msg and msg["error"] is not None: err = msg["error"] + if not isinstance(err, dict): + raise GatewayError("JSON-RPC error was not an object") raise RpcError( code=int(err.get("code", 0)), message=str(err.get("message", "unknown error")), @@ -190,19 +197,40 @@ class GatewayClient: def __init__(self, cfg: GatewayConfig, timeout: float = DEFAULT_TIMEOUT) -> None: self.cfg = cfg self.url = cfg.mcp_url() + _u = httpx.URL(self.url) + self._origin = (_u.scheme, _u.host, _u.port) self._session_id: str | None = None self._next_id = 0 # verify: add the MITM CA to the system trust store (additive, matching - # the Rust client's add_root_certificate) rather than replacing it. + # the Rust client's add_root_certificate) rather than replacing it. A + # missing/invalid bundle surfaces as GatewayError, not a raw ssl/OSError. verify: ssl.SSLContext | bool = True if cfg.ca_bundle: ctx = ssl.create_default_context() - ctx.load_verify_locations(cafile=cfg.ca_bundle) + try: + ctx.load_verify_locations(cafile=cfg.ca_bundle) + except (OSError, ssl.SSLError) as e: + raise GatewayError(f"CA bundle {cfg.ca_bundle}: {e}") from e verify = ctx - # trust_env=True (default) honors HTTPS_PROXY/HTTP_PROXY. - # follow_redirects mirrors reqwest's default (the Rust client follows up - # to 10); the gateway's trailing-slash /mcp/{key}/ path can 307-normalize. - self._http = httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) + # trust_env=True (default) honors HTTPS_PROXY/HTTP_PROXY. follow_redirects + # mirrors reqwest (the Rust client follows up to 10; cap it the same so + # redirect-exhaustion behavior matches). The request hook strips the + # credential headers on any cross-origin redirect so a redirect to a + # different host can't carry sealg's secret key or session id off the + # configured gateway origin. + self._http = httpx.Client( + timeout=timeout, + verify=verify, + follow_redirects=True, + max_redirects=10, + event_hooks={"request": [self._strip_creds_off_origin]}, + ) + + def _strip_creds_off_origin(self, request: httpx.Request) -> None: + origin = (request.url.scheme, request.url.host, request.url.port) + if origin != self._origin: + for header in (SECRET_KEY_HEADER, CONVERSATION_ID_HEADER, "Mcp-Session-Id"): + request.headers.pop(header, None) def __enter__(self) -> Self: self.connect() @@ -289,9 +317,10 @@ def _rpc_capture_session( # Anything else (an un-followed 3xx after redirect exhaustion, an auth # 401) is an HTTP-level failure, not a body for the JSON-RPC parser. if not (200 <= resp.status_code < 300): - raise GatewayError( - f"gateway returned HTTP {resp.status_code}: {resp.text[:512]}" - ) + # The body may echo the /mcp/{key}/ request URL, so redact the key + # before it reaches stderr/logs. + body = redact_url(resp.text[:512], self.cfg.api_key) + raise GatewayError(f"gateway returned HTTP {resp.status_code}: {body}") result = _extract_rpc_result( resp.headers.get("content-type", ""), resp.text, rpc_id ) diff --git a/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc b/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc index 4b807846638bb72109958185944189663419d3dd..3c55bc210f7e89468abded086266fad96a0d73f8 100644 GIT binary patch delta 5993 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 delta 1421 zcmZvcVN4uV5PP+fmQ=Ol^Tk)bt@cql?G_6X>6=jYa?QVr5wj{w;XqHyUXmB zJ1C~X`lAPd7N(Y3F~v4Y2-HwFG5#v5)(A+KcUb{j3AmJM)s`<6DT}n`hQ`I+8JBsix&fprlRJ zga9qvgLPeRA4!u)=XF%Y)Z5H3>V#dDL`?XN_9%;)7orM1h2V;0!eN`OiX5Icv z9cTMHxHuZnbgPE00Vss*1C8eE1V5pNV+pUMqT+{K%HdC`8D`=@+p~7IU-FmAY*V5L zX$|vwZAlmRp-X%bZ|SF40UzlG>&A>R8@$LZ#iJz9KeUIP#fG7nc!_Jz+n0tGeC!*H z499RGTP0W}c=vE6{wY=9-@}!=X&df0CbWBG|H;jYe#_B6K)Xrl&hxr&s6c}T&rD`|3P3g_}ge7o5!Q)>Vh9}8+=T-f*XhGE5yX0ZmMzs zJ|W#aE}!cb7r1!ME+0E7va1*yA7D4}$8kJC4E}b7lE#xveyC`8V+#C+ud#a z=T;nF9E=1Qi4)iUGhQI(_$=KVEp9rYdvN8GA_(|Do~dFCq3{+#dyhsijKd4uaXS7>1?_)8%r zEs?`%JUpTBG~Jk}47vrqLfQ%ZZemyUB8jVnJA|(| None: f"{SNAPSHOT[snap_key]!r} (parser or request builder moved)" ) + # EXIT_ERROR (1) is the generic client/transport failure code. The Rust CLI + # exits it inline (`std::process::exit(1)`) rather than via a named const, so + # assert that literal appears in gateway_cmd.rs to keep the 0/1/6 parity that + # contract.py advertises under enforcement on both sides. + if f"exit({SNAPSHOT['EXIT_ERROR']})" not in gateway_cmd.replace(" ", ""): + problems.append( + f"Rust gateway_cmd.rs no longer exits {SNAPSHOT['EXIT_ERROR']} on client failure " + "(contract.py EXIT_ERROR)" + ) + def main() -> None: problems: list[str] = []