fix(browser): close remaining CDP-URL leak paths in supervisor (review)

Review of the salvage found the timeout-message redaction left the more
common failure mode unguarded: when the first websockets.connect(cdp_url)
fails (bad URI / refused / TLS), the raw websockets exception -- which
embeds the full cdp_url incl. ?token= and user:pass@ -- is stashed as
_start_error and re-raised verbatim by start(), and two reconnect
logger.warning sites log the same raw exception.

Add a module-level _redact_cdp_error_text() chokepoint (delegating to
agent.redact.redact_cdp_url) and route all four supervisor egress points
through it:
- start() TimeoutError message (already covered; kept)
- start() _start_error re-raise -> now raises a redacted RuntimeError with
  'from None' so no secret leaks via message OR traceback cause chain
- connect-failed and session-dropped reconnect warnings

Guard tests assert the re-raised message is redacted for both token and
userinfo, the raw cause is suppressed, and the helper preserves non-secret
context (host/reason). Verified with a mutation check: reverting to the raw
'raise err' fails the new tests. Correct the redact_cdp_url docstring to
scope its guarantee to direct-URL redaction and point exception callers at
the supervisor helper.
This commit is contained in:
kshitijk4poor
2026-07-01 13:37:25 +05:30
committed by kshitij
parent c626dded13
commit e09ff88d02
3 changed files with 126 additions and 7 deletions
+29 -3
View File
@@ -34,6 +34,25 @@ from websockets.asyncio.client import ClientConnection
logger = logging.getLogger(__name__)
def _redact_cdp_error_text(exc: object) -> str:
"""Redact any CDP endpoint credentials from an error's string form.
``websockets`` bakes the raw target URL into its exception messages
(``InvalidURI``, connection errors, TLS failures all embed the full
``self.cdp_url`` — including a ``?token=`` query credential or
``user:pass@`` userinfo). Every supervisor egress point that turns such an
exception into log text or a re-raised message MUST route through here so
those credentials never reach Hermes logs or tracebacks. Falls back to a
fixed sentinel if redaction itself raises, erring toward masking.
"""
try:
from agent.redact import redact_cdp_url
return redact_cdp_url(str(exc))
except Exception:
return "<error redacted>"
# ── Config defaults ───────────────────────────────────────────────────────────
DIALOG_POLICY_MUST_RESPOND = "must_respond"
@@ -353,7 +372,14 @@ class CDPSupervisor:
if self._start_error is not None:
err = self._start_error
self.stop()
raise err
# ``err`` is a raw ``websockets`` exception whose message embeds the
# full cdp_url (token / userinfo). Re-raise a redacted RuntimeError
# and suppress the raw cause (``from None``) so no credential leaks
# via the message OR the traceback chain. Type is not load-bearing:
# the sole caller (_ensure_cdp_supervisor) only logs it.
raise RuntimeError(
f"CDP supervisor failed to start: {_redact_cdp_error_text(err)}"
) from None
def stop(self, timeout: float = 5.0) -> None:
"""Cancel the supervisor task and join the thread."""
@@ -631,7 +657,7 @@ class CDPSupervisor:
return
logger.warning(
"CDP supervisor %s: connect failed (attempt %s): %s",
self.task_id, attempt, e,
self.task_id, attempt, _redact_cdp_error_text(e),
)
await asyncio.sleep(min(backoff, 10.0))
backoff = min(backoff * 2, 10.0)
@@ -668,7 +694,7 @@ class CDPSupervisor:
"CDP supervisor %s: session dropped after %.1fs: %s",
self.task_id,
time.time() - last_success_at,
e,
_redact_cdp_error_text(e),
)
finally:
with self._state_lock: