Repository navigation
feat: let external agents drive codeoid sessions over MCP (/mcp/control) - #380
Merged
Merged
Conversation
…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]>
KunalJavelin
approved these changes
Oct 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: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 entryThe 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.
requireDelegatedaccepts 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.pagewithout an anchor returns the newest page;SessionInfo.turnQueuedcovers the moment between "prompt accepted" and "turn started", when a session still readsidle;SessionInfo.managedBymarks 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:
create_session, thensend_messagewithwait, returned the real reply (PONG-251) in 7 s;touch created-by-agent.txtcame backwaiting_approval,get_sessionnamed the parkedBashcall, and the file was never created;mcp.control.callrow for every call, alongside the ordinarysession.create/session.send/session.interrupt/session.destroyrows, with onesession.sendfor 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 realSessionManagerwith 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.
Did not run:
highflame-ai/codeoidis not inenabled_repos.Not in this PR
codeoid mcp headersmints a token from the configured key, andrequireDelegatedonly accepts tokens that were already delegated.read_transcriptwith abeforecursor 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.codeoid-ui's protocol crate should gain the optionalbeforeMessageId,turnQueued, andmanagedByfields. Nothing breaks without them.For agents: implementation map
src/daemon/mcp/control-http.ts:ControlMcpHttp. The tool table isbuildTools, and#callToolbuilds aCallContextwhosecall()runsparseClientMessageand thenControlHost.handle(SessionManager.handle), raced against a per-call deadline; the whole tool also races a tool deadline.actionTargetfails closed withoutsession:listand refusesrole,collaboration,collaborationRoleandmanagedBy. Waits are capped at 16 per subject;send_messageclaims its slot before sending. After a delivered prompt, failures returnsent: true(wait_failed), and a send that hits its deadline returnssend_unconfirmed. The whole-tool deadline signals a wind-down 5 s before its hard cut, so a wait ends asstill_workingfirst.replyToanchors on the caller's prompt text. Body reads go through a streaming cap (readCapped). The idle timeout is lifted only for authenticatedtools/callafter the body is in, and restored infinally, 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);idleTimeoutis explicit viaIDLE_TIMEOUT_SECONDS.src/daemon/auth.ts:ZeroIdVerifier.decodeLikeSdk/jwtKeyRefmirror the SDK's header decode. Invalid headers are refused. An unseen stringkidis 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:#listaddsmanagedByfromPipelineManager.activeSessionIds, which isPipelineStore.activeSessionIds(SQL,json_valid-guarded).src/daemon/session.ts: the newest-page branch ofpageScrollback;toInfo().turnQueuedfrompreparingTurn.src/config.ts:McpControlSchema, env overrides,isLoopbackUrl.resolveLocalTokenreads the token file only for loopback URLs.src/terminal/token.tsandmcp-control-cli.ts:acquireToken, now shared withTerminalClient, scope presets,controlUrl, and themcp headers/mcp configlogic. The helper command is rebuilt only from validated scopes.docs/mcp-control.md(user guide and security model),CHANGELOG.md,docs/CONFIGURATION.md,docs/FEATURES.md,CLAUDE.md.🤖 Generated with Claude Code