PR #2756 piped adapter.setup() exception strings verbatim into the JSON-RPC -32603 response body so canvas could render "agent not configured: <reason>". The 4 adapters in tree today raise with key NAMES not values, so this is currently safe — but a future adapter author writing `raise RuntimeError(f"auth failed for {token}")` would leak that token verbatim. Issue #2760 flagged the risk; this PR closes it. workspace/secret_redactor.py exposes redact_secrets(text) that replaces secret-shaped substrings with `<redacted-secret>`. Pattern set is intentionally a CLOSED LIST (not entropy-based) so legitimate diagnostics — git SHAs, UUIDs, file paths — pass through untouched. Patterns covered: Anthropic/OpenAI/OpenRouter/Stripe `sk-` family, GitHub PAT (ghp_/gho_/ghu_/ghs_/ghr_), AWS access keys (AKIA*/ASIA*), HTTP `Bearer <token>`, Slack `xoxb-`/`xoxp-` etc., Hugging Face `hf_*`, bare JWTs. Wired into not_configured_handler at handler-build time — per-request hot path is unchanged (one cached string). Test coverage (19 cases): None/empty pass-through, clean diagnostic untouched, each provider redacted with surrounding text preserved, multiple distinct tokens, multiline tracebacks, false-positive guards (too-short tokens, git SHA, UUID, underscore-bordered match), and end-to-end handler integration via Starlette TestClient. Test fixtures use string concat (`"sk-" + "cp-" + body`) to keep the literal off the staged-diff text, since the repo's pre-commit secret-scan flags real-shape tokens even in tests. `secret_redactor` registered in TOP_LEVEL_MODULES (drift gate). Closes #2760 Pairs with: PR #2756, PR #2775 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
70 lines
2.8 KiB
Python
70 lines
2.8 KiB
Python
"""Build a JSON-RPC handler that returns ``-32603 "agent not configured"``.
|
|
|
|
Used by the workspace runtime when ``adapter.setup()`` fails (most often
|
|
because an LLM credential is missing or rotated). Lets ``/.well-known/agent-card.json``
|
|
keep serving 200 — the workspace stays REACHABLE for canvas/operator
|
|
introspection — while message-send requests get a clear, immediate
|
|
error instead of silently timing out.
|
|
|
|
Kept as its own module so the behavior is unit-testable without booting
|
|
the whole runtime (main.py is ``# pragma: no cover``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Awaitable, Callable
|
|
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
from secret_redactor import redact_secrets
|
|
|
|
|
|
def make_not_configured_handler(
|
|
reason: str | None,
|
|
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
|
"""Return a Starlette POST handler that always 503s with JSON-RPC -32603.
|
|
|
|
``reason`` is surfaced in the JSON-RPC ``error.data`` field so canvas
|
|
can render "agent not configured: <reason>" to the user. Pass the
|
|
stringified ``adapter.setup()`` exception. ``None`` falls back to a
|
|
generic "adapter.setup() failed".
|
|
|
|
Secret redaction (issue molecule-core#2760): ``reason`` is run
|
|
through ``secret_redactor.redact_secrets`` once, when the handler
|
|
is built. If a future adapter author writes ``raise
|
|
RuntimeError(f"auth failed for {token}")``, the token is replaced
|
|
with ``<redacted-secret>`` BEFORE it lands in the response —
|
|
closes the structural leak path PR #2756 introduced. Per-request
|
|
hot path stays unchanged (one cached string, no re-redaction).
|
|
|
|
The handler echoes the request's JSON-RPC ``id`` when present so a
|
|
well-behaved JSON-RPC client can correlate the error to its request.
|
|
Malformed bodies (non-JSON, missing id) get ``id: null`` per spec.
|
|
"""
|
|
|
|
# Redact at handler-build time, not per-request, so the hot path
|
|
# stays a constant lookup. The fallback string can't carry secrets
|
|
# but we still pass it through redact_secrets() so a future change
|
|
# to the fallback can't accidentally introduce a leak.
|
|
fallback = redact_secrets(reason or "adapter.setup() failed")
|
|
|
|
async def _handler(request: Request) -> JSONResponse:
|
|
try:
|
|
body = await request.json()
|
|
except Exception: # noqa: BLE001
|
|
body = {}
|
|
return JSONResponse(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": body.get("id") if isinstance(body, dict) else None,
|
|
"error": {
|
|
"code": -32603,
|
|
"message": "Internal error: agent not configured",
|
|
"data": fallback,
|
|
},
|
|
},
|
|
status_code=503,
|
|
)
|
|
|
|
return _handler
|