Skip to content

Require a per-start token on the local bridge and supervise it - #16

Merged
christopherjnelson merged 2 commits into
mainfrom
claude/optimistic-shannon-4dh7gs
Sep 28, 2026
Merged

christopherjnelson merged 2 commits into
mainfrom
claude/optimistic-shannon-4dh7gs

Conversation

@christopherjnelson

Copy link
Copy Markdown
Member

The local CLI bridge accepted any request on its loopback port. A sandboxed Worker CLI shares the host network, so it could drive the bridge API directly, and so could a DNS-rebinding page. Foreman also never noticed when a bridge died.

Bridge (investigations/local-cli-uhp/server.mjs)

  • Token:
    • When LOCAL_CLI_UHP_TOKEN is set, every route requires Authorization: Bearer <token>, including discovery.
    • The token is compared as SHA-256 digests with timingSafeEqual.
    • The variable is removed from process.env at startup. The CLI sandboxes already use env allowlists and their own PID namespace, so they cannot read it.
    • Without a token the bridge still accepts requests and prints one warning.
  • Host check: the Host header must be 127.0.0.1, localhost or [::1] on the bound port, with or without a token. Anything else gets 403.
  • Other fixes:
    • A malformed request URL now returns 400 instead of crashing the bridge.
    • A port that cannot be bound exits 1 at once.

Foreman (src/local-bridge.ts, server.ts, controller.ts, verified-workspace.ts)

  • Token:
    • Each per-project bridge start generates a 32-byte token and passes it through the bridge's environment.
    • Every Foreman call sends it: UHP discovery and turns (via bearerFetch), workspace seed, overlay, snapshot, and usage.
    • It is non-enumerable on the status object, so it never appears in API responses, events or logs.
    • Bridge helpers only send it to loopback URLs, and a 401 or 403 error message never includes it.
  • Log: stdout and stderr go to <state dir>/bridge.log (mode 0600). At each (re)start the log is rotated to bridge.log.1 if it is over 5 MiB.
  • Supervision:
    • A bridge that exits after becoming ready is restarted on the same port with the same token, so URLs and credentials already handed out stay valid.
    • Backoff starts at 1 s and doubles, capped at 30 s.
    • It gives up after 5 consecutive runs that each ended within 60 s of starting.
    • A deliberate stop never triggers a restart.
  • Status: workspace-setup reports the bridge as ready, restarting or unavailable, with its last exit, restart count and log path.
  • Recovery: reopening a project whose bridge gave up starts a fresh one.
  • Manually started bridge: FOREMAN_WORKSPACE_BRIDGE_TOKEN (requires FOREMAN_WORKSPACE_BRIDGE_URL) and UHP_TOKEN cover this case. UHP_TOKEN is now also sent on UHP discovery.

Tests

  • tests/local-bridge.test.ts (+15):
    • Token generation and non-enumerability.
    • Readiness 401 fails fast.
    • Restart on the same port and token.
    • Backoff with fake timers.
    • Give-up, and the stable-run reset.
    • No restart after stop().
    • Log permissions and rotation.
    • A real kill -9 of the bridge process.
  • tests/bridge-token.test.ts (new):
    • The helpers send the token and never send it off loopback.
    • A static guard that every controller bridge call passes the token.
    • The Controller against a fake bridge that requires the token.
  • tests/config-bridge-token.test.ts (new): config validation, which never echoes the value.
  • Bridge suite (+8):
    • 401 across 10 routes × 10 bad Authorization headers, with no state written.
    • 403 across 15 foreign Hosts.
    • The startup warning, and a bad token value exits without printing it.
    • Malformed URL, and an occupied port.
    • The Codex Worker flow end to end against a bridge that requires a token.

Verification

  • Typecheck is clean.
  • vitest: 540/540 passing.
  • pnpm test:bridge: 62/62 passing.
  • Build succeeds.
  • A live run against a real Foreman server and bridge:
    • Missing or wrong token gets 401, and a foreign Host gets 403.
    • After kill -9 the bridge was ready again in 1.3 s on the same port with the same token.
    • Four more kills made it report unavailable with the give-up message, and reopening the project started a fresh bridge.
    • The token appeared nowhere in API responses, logs or the data directory.

Follow-ups, not in this PR

  • The bridge log is only size-checked at (re)start.
  • Bridge health is not shown in the UI yet.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DXCvrGWmmRNDLmy7nYS2QP


Generated by Claude Code

The local CLI bridge accepted any request on its loopback port, so a
sandboxed Worker CLI (which shares the host network) or a DNS-rebinding
page could drive it. Foreman also never noticed when the bridge died.

Bridge (investigations/local-cli-uhp/server.mjs):
- When LOCAL_CLI_UHP_TOKEN is set, every route requires
  `Authorization: Bearer <token>`, compared as SHA-256 digests with
  timingSafeEqual. The variable is removed from process.env at startup.
  Without a token it still accepts requests and warns once.
- The Host header must be 127.0.0.1, localhost or [::1] on the bound
  port, with or without a token.
- A malformed request URL returns 400 instead of crashing the bridge,
  and a port that cannot be bound exits 1 at once.

Foreman (src/local-bridge.ts, server.ts, controller.ts):
- Each per-project bridge start generates a 32-byte token. It is passed
  to the bridge through its environment and used by every Foreman call:
  UHP discovery and turns (via bearerFetch), workspace seed, overlay,
  snapshot and usage. The status object keeps it non-enumerable so it
  never appears in API responses, events or logs.
- The bridge's stdout and stderr go to <state dir>/bridge.log (0600),
  rotated to bridge.log.1 past 5 MiB at each (re)start.
- A bridge that exits after becoming ready is restarted on the same port
  with the same token, with backoff of 1 s doubling to 30 s. After 5
  consecutive runs that each ended within 60 s of starting it gives up.
  A deliberate stop never triggers a restart.
- workspace-setup reports the bridge as ready, restarting or unavailable
  with its health (last exit, restart count, log path). Reopening a
  project whose bridge gave up starts a fresh one.
- FOREMAN_WORKSPACE_BRIDGE_TOKEN (requires FOREMAN_WORKSPACE_BRIDGE_URL)
  and UHP_TOKEN cover a manually started bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXCvrGWmmRNDLmy7nYS2QP
Foreman's notes in bridge.log were fire-and-forget appends, so start()
could resolve before "starting bridge" reached the file (the log
rotation test failed in CI, and 4 of 24 local runs under load), two
notes could land out of order, and a note about the previous run could
race the rotation at restart.

Notes now go through one ordered queue. launch() waits for pending notes
before rotating, and the startup path awaits its own notes. The ready
note is written before the bridge is published as ready, so the ready
state and its health notification still happen with no await between
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXCvrGWmmRNDLmy7nYS2QP
@christopherjnelson
christopherjnelson merged commit 94aad4b into main Sep 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants