Skip to content

feat: let external agents drive codeoid sessions over MCP (/mcp/control) - #380

Merged
saucam merged 7 commits into
mainfrom
feat/mcp-control-plane-251
Oct 12, 2026
Merged

saucam merged 7 commits into
mainfrom
feat/mcp-control-plane-251

Conversation

@saucam

@saucam saucam commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #251

What

Other agents can now drive codeoid over MCP. Claude Code, Cursor, or a CI job mounts one URL, /mcp/control, and can:

  • list sessions, read their transcripts and token/cost usage, and wait for one to finish;
  • when the operator allows it, create sessions, prompt them (and get the reply back), interrupt them, and destroy them.

It is off by default. Turning it on makes it observe-only; acting is a second, separate switch.

codeoid mcp config --scopes operate   # prints a ready .mcp.json entry

The entry uses Claude Code's headersHelper, so the client mints a fresh, scope-narrowed token on every connect and after any 401. A short-lived ZeroID token keeps working in a long-lived mount without anyone pasting a token. The default scope preset is observe-only.

How it stays safe

This endpoint can start agents and prompt them, so it gets no shortcuts.

  • No second authorization path. The token is checked by the same verifier as the WebSocket login. Each tool becomes an ordinary protocol message, handled by the same function that enforces scopes, tenancy, and audit for every other client. A token that can't create a session over WebSocket can't create one here.
  • Driving is not authorizing. There is no approve tool. A session prompted from outside still parks every guarded tool call on its owner's approval. In the live run below, the agent asked to run a shell command and nothing happened until a person approved it.
  • Hands off what codeoid steers. The conductor and its workers, collaborative sessions, and a session a live pipeline run is driving can be read from outside, but not prompted, interrupted, or destroyed.
  • Every call is attributed in the audit log to the caller, with the principal that delegated to it. A prompt an agent sends shows up in the transcript as that agent's message.
  • requireDelegated accepts only delegated agent tokens, so each outside caller is its own revocable principal.

One change reaches beyond the endpoint. Forged tokens can no longer make the daemon refetch ZeroID's signing keys in a loop, over this endpoint or the WebSocket login. Before this, a stream of tokens naming made-up key ids cost one fetch each.

Small protocol additions, all optional:

  • scrollback.page without an anchor returns the newest page;
  • SessionInfo.turnQueued covers the moment between "prompt accepted" and "turn started", when a session still reads idle;
  • SessionInfo.managedBy marks a session a pipeline run is driving.

Verification

Live run against a separate local-mode daemon (its own port, database, and config), driven by the official MCP SDK client, with real Claude:

  • a forged token was refused at connect;
  • create_session, then send_message with wait, returned the real reply (PONG-251) in 7 s;
  • a retried send with the same idempotency key was delivered once;
  • a prompt asking the agent to run touch created-by-agent.txt came back waiting_approval, get_session named the parked Bash call, and the file was never created;
  • interrupt, wait, usage, and destroy all worked;
  • the audit log held an mcp.control.call row for every call, alongside the ordinary session.create / session.send / session.interrupt / session.destroy rows, with one session.send for the de-duplicated retry.

Tests: the full suite passes (3,099 pass, 0 fail; the skip count depends on which backends are installed on the machine), and so do the web typecheck and its 593 tests. New coverage is in mcp-control.test.ts (a real SessionManager with a scripted backend, a fake host for edge cases, and two real daemons for the HTTP mount), mcp-control-cli.test.ts, auth-kid-cooldown.test.ts, and additions to the config, scrollback, session, pipeline-store and pipeline-handler tests. Each security guard was checked by reverting it: removing the guard fails at least one test.

Audit: four rounds. Each round paired a general correctness audit with a security review by a reviewer that had not seen earlier rounds. Every finding was fixed in this branch, one commit per round.

Round Correctness Security Fixed in
1 ship-with-fixes 1 High: a send-only token bypassed the conductor/worker refusal; 4 Mediums 4077a17
2 ship-with-fixes 1 High: my failed-login throttle let any local process lock out every valid caller 93810ab
3 ship-with-fixes 1 High: a header-parser mismatch bypassed the forged-key gate; keep-alive sockets kept a lifted timeout af10260
4 ship-with-fixes, no High no Critical/High; 1 Medium (a retired key stayed trusted), 1 Low 87744d4
Final ship 4 Lows, including a valid rotated key that could be refused for up to 10 s see below

Did not run:

  • The Highflame compose stack and the kind regression suite don't apply: codeoid is not part of either, so the live daemon run above takes their place.
  • Oracle doesn't review this repo: highflame-ai/codeoid is not in enabled_repos.
  • Round 4 was the last full round. The final audit then reviewed the whole diff, including the round-4 fixes (87744d4), and returned ship. Its four Lows are fixed in the latest commit, each with a test that fails when the fix is reverted; nobody re-reviewed that last commit.

Not in this PR

  • Per-agent delegated handshake. An outside agent should trade its own credential for a codeoid-scoped delegated token, so each caller sits in the ZeroID tree under its own identity. Today codeoid mcp headers mints a token from the configured key, and requireDelegated only accepts tokens that were already delegated.
  • Transcript scan throttle. read_transcript with a before cursor that isn't in memory scans the on-disk transcript (bounded at 64 MB and 5 s), the same as the WebSocket path. Throttling that is a follow-up.
  • Rust protocol parity. codeoid-ui's protocol crate should gain the optional beforeMessageId, turnQueued, and managedBy fields. Nothing breaks without them.
For agents: implementation map
  • src/daemon/mcp/control-http.ts: ControlMcpHttp. The tool table is buildTools, and #callTool builds a CallContext whose call() runs parseClientMessage and then ControlHost.handle (SessionManager.handle), raced against a per-call deadline; the whole tool also races a tool deadline. actionTarget fails closed without session:list and refuses role, collaboration, collaborationRole and managedBy. Waits are capped at 16 per subject; send_message claims its slot before sending. After a delivered prompt, failures return sent: true (wait_failed), and a send that hits its deadline returns send_unconfirmed. The whole-tool deadline signals a wind-down 5 s before its hard cut, so a wait ends as still_working first. replyTo anchors on the caller's prompt text. Body reads go through a streaming cap (readCapped). The idle timeout is lifted only for authenticated tools/call after the body is in, and restored in finally, because Bun scopes it to the socket and caps it near 255 s.
  • src/daemon/mcp/control-path.ts: the path constant, kept separate so the CLI doesn't import the endpoint.
  • src/daemon/server.ts: the mount (404 when off); idleTimeout is explicit via IDLE_TIMEOUT_SECONDS.
  • src/daemon/auth.ts: ZeroIdVerifier. decodeLikeSdk / jwtKeyRef mirror the SDK's header decode. Invalid headers are refused. An unseen string kid is checked against a key-id inventory read at most every 10 s (1 s after a failure). A trusted kid is forgotten when the SDK reports it unpublished, and pruned when a fresh list drops it.
  • src/daemon/session-manager.ts: #list adds managedBy from PipelineManager.activeSessionIds, which is PipelineStore.activeSessionIds (SQL, json_valid-guarded).
  • src/daemon/session.ts: the newest-page branch of pageScrollback; toInfo().turnQueued from preparingTurn.
  • src/config.ts: McpControlSchema, env overrides, isLoopbackUrl. resolveLocalToken reads the token file only for loopback URLs.
  • src/terminal/token.ts and mcp-control-cli.ts: acquireToken, now shared with TerminalClient, scope presets, controlUrl, and the mcp headers / mcp config logic. The helper command is rebuilt only from validated scopes.
  • Docs: docs/mcp-control.md (user guide and security model), CHANGELOG.md, docs/CONFIGURATION.md, docs/FEATURES.md, CLAUDE.md.

🤖 Generated with Claude Code

saucam and others added 6 commits October 12, 2026 06:07
…rive sessions

Adds /mcp/control: Claude Code, Cursor, or a CI job can list sessions,
read their transcripts and usage, wait on them, and (when the operator
allows actions) create, prompt, interrupt and destroy them.

It adds no authorization path of its own. The bearer token is verified
per request by the WebSocket handshake's verifier, and every tool is an
ordinary ClientMessage validated by the protocol schema and run through
SessionManager.handle, so scopes, tenancy and audit are the ones every
client gets. There is no approve tool: a driven session still parks
guarded tool calls on its owner's approval.

- off by default; observe-only unless mcpControl.allowActions
- requireDelegated accepts only delegated agent tokens
- Origin allowlist (DNS rebinding), header-only token, body cap
- dispatcher-owned sessions (conductor, workers) are read-only here
- every call audited as mcp.control.call under the caller, with its delegator
- scrollback.page without an anchor serves the newest page
- SessionInfo.turnQueued closes the idle-before-turn-start window for waits
- codeoid mcp headers|config: a headersHelper that re-mints a scope-narrowed
  token on each connect and after a 401

Closes #251

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Security review and general audit of the control-plane endpoint:

- Act tools now fail closed without session:list. A send-only token made
  name resolution fall back to a bare id with no role, so the
  conductor/worker refusal never ran (send to the conductor and destroy of
  a worker both succeeded). Collaborative orchestrators and their role
  children are refused too: prompting one is dispatch by proxy.
- The audit row records only a known tool name; a caller-chosen name with
  spaces could forge `outcome=` and `delegatedBy=` fields.
- At most 16 concurrent waits per subject (each polls the session list).
- Failed authentication is throttled per address before the verifier
  runs, so forged tokens with random `kid`s can't drive JWKS refetches.
- Bodies are read through a streaming byte cap; a chunked body with no
  Content-Length was buffered whole before the size check.
- send_message wait returns the system notices that explain an `error`
  outcome; a session destroyed mid-wait reports `destroyed`.
- The request timeout is set explicitly for tool calls (Bun 1.3.13 does
  not cut POSTs at idleTimeout today; this states the bound instead of
  relying on that).
- CLI: preset lookup is own-property only (`--scopes constructor` threw),
  and `mcp config` warns when the token would cross the network in clear.
- CONTROL_MCP_PATH moves to its own module so the CLI doesn't import the
  endpoint; changelog entry; protocol note that older daemons reject an
  anchor-less scrollback.page; allowedOrigins stays file-only, by design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Drop the per-address failed-auth throttle. On a loopback daemon every
  caller shares one address, so 20 token-less POSTs a minute locked out
  every valid caller — and it never covered the WebSocket handshake. The
  real cost (the SDK refetching ZeroID's JWKS for every unknown `kid`) is
  now bounded in ZeroIdVerifier for both transports: an unseen `kid` is
  checked against the issuer's published key ids, re-read at most once per
  10 s, before reaching the SDK. Spacing by time rather than by failures
  means a forged stream cannot starve a genuinely rotated key.
- Sessions a live pipeline run is driving are marked
  SessionInfo.managedBy="pipeline" (derived from the pipeline store, so it
  survives restarts and clears when the run ends) and refused by act tools.
- send_message claims its wait slot before sending, and after a delivered
  prompt any wait failure returns sent:true (outcome wait_failed) — never a
  tool error a caller would retry into a second billed turn.
- The reply is anchored on this prompt's text, and stops at the next prompt.
- The audit row's session id is recorded only for a session actually found.
- Bun caps server.timeout near 255 s (a 300 s setting cut a 270 s hold at
  256 s), below maxWaitSeconds' 600 s ceiling, so the idle timeout is now
  disabled for authenticated tool calls after the body is in; every wait
  ends itself by maxWaitSeconds.
- The local-mode token FILE is used only for a loopback daemon URL; a
  remote URL on the same port no longer picks it up. `mcp headers` warns on
  cleartext off-host URLs too.
- Tests now fail when the guarded logic is broken: streaming body cap,
  observer cleanup, timeout scoping, audit ids on the raw-reference path.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- The forged-kid gate decoded the JWT header differently from the ZeroID
  SDK, so `kid: 1` / `true` / `{}` (and a BOM-prefixed header) skipped the
  gate while the SDK still refetched JWKS for each token — ~30 issuer
  fetches per 30 forged tokens. The header is now decoded exactly as the
  SDK decodes it, and an undecodable header or a truthy non-string `kid`
  is refused before the SDK. The reviewer's PoC now measures 0-1 fetches.
- Lifting the idle timeout for a tool call stuck to the keep-alive
  SOCKET in Bun, so one valid call let that connection — and a half-sent
  next request on it — stay open forever. The lift is restored in a
  finally before the response, and Bun.serve's idleTimeout is set from the
  same constant the restore uses.
- Every daemon call made for a tool has a hard deadline, so a hung backend
  can't hold a lifted socket open.
- SessionInfo.managedBy is answered in SQL (status-filtered,
  json_extract) instead of parsing every pipeline the tenant ever ran on
  each session.list — ~10 ms per call at 100 runs.
- The key-id inventory reads the URL the SDK reads (trailing slash
  stripped; config.jwksUrl documented as unused), and retries a failed
  read after 1 s rather than a full 10 s window.
- isLoopbackUrl reuses the daemon's isLoopbackHost, plus the two client-
  only forms (0.0.0.0, hex IPv4-mapped), so `ws://0.0.0.0` keeps its local
  token. `mcp headers` warns when a pinned local token targets a remote host.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- A signing key that verified once stayed trusted forever, so after the
  issuer retired it, forged tokens naming that public kid skipped the gate
  and cost the SDK a JWKS refetch each (50 for 50 in the PoC). A kid is
  forgotten as soon as the SDK reports it unpublished, and a fresh key
  list prunes trusted kids it no longer contains.
- activeSessionIds guards json_extract with json_valid: one corrupt
  pipeline row failed every session.list for the tenant, across restarts.
- Each tool is bounded as a whole (maxWaitSeconds + 60 s), not only each
  daemon call inside it — their sum is what holds a lifted socket.
- A session.send that hits its deadline reports send_unconfirmed ("may
  still be delivered; retry only with the same idempotencyKey") instead of
  an error that reads as "nothing happened".
- The key-list URL strips one trailing slash, exactly as the SDK does; the
  listActive doc comment is back on listActive.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- After the SDK reports a key it doesn't have, the next key-list read is
  pulled forward to 1 s. The SDK's refetch can miss a key the issuer does
  publish (a fetch in flight from before a rotation, a stale replica), and
  forgetting it then refused that valid key for a full 10 s window.
- The whole-tool deadline now signals the tool to wind down 5 s before
  its hard cut: a wait returns still_working on its next poll, so a
  send_message whose prompt was delivered answers sent:true instead of a
  bare timeout error (keeps the documented promise).
- A test pins the key-list URL to the SDK's exactly (it strips one
  trailing slash), so that fix is guarded like the rest.
- docs: send_unconfirmed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@saucam
saucam merged commit d5a6b10 into main Oct 12, 2026
4 checks 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.

Outward control-plane MCP server (/mcp/control) — let external agents drive codeoid sessions

2 participants