Fix 2s slowloris kill of confirmation-gated control requests; add global seamless agent messaging toggle - #113
Conversation
The hook server's 2s receive-phase guard applied to the whole request lifetime, so a write/close control request parked on the user's confirmation dialog (up to 120s by design) had its socket destroyed after 2s -- the calling shim reported 'endpoint unreachable' while the dialog was still up, and stacked confirmations wedged all agent messaging. The guard now disarms once the body is fully read, on the control and context-link routes. Adds Settings -> Agents 'Seamless agent messaging' (agentSeamlessWrites, default OFF): documented as pre-approving agent-to-node write delivery without the per-message dialog; close always confirms. NOTE: the Canvas write-path branch that honors the toggle is intentionally not included in this commit; until it lands the toggle is visible but inert.
There was a problem hiding this comment.
Pull request overview
Fixes agent control requests being prematurely killed by the slowloris receive-timeout, and adds an opt-in trust model to reduce repeated confirmations for agent-to-node write operations (global toggle + per-node-pair grants).
Changes:
- Disarms the slowloris socket timeout after request bodies are fully read for
/control/*and/context-link/*routes so confirmation-gated requests can legitimately wait. - Introduces “Seamless agent messaging” global setting (default off) and per-project trusted node-pair grants for agent
writerequests. - Adds UI in Settings → Agents to manage the global toggle and to clear per-project trusted-pair grants; prunes grants when nodes are deleted.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/shared/types.ts |
Adds Project.seamlessPairs and Settings.agentSeamlessWrites plus default setting value. |
src/renderer/state/projects.ts |
Adds project state mutations for adding/pruning/clearing trusted node-pair grants. |
src/renderer/components/settings/sections/AgentsSection.tsx |
Adds Settings UI for the global seamless toggle and a live count + “Clear all” for trusted pairs. |
src/renderer/canvas/Canvas.tsx |
Implements per-pair trust checkbox in the write confirmation dialog, seamless-delivery fast path, and pruning on node deletion. |
src/core/agents/hook-server.ts |
Disarms the slowloris timeout post-body-read for control/context-link requests to allow long confirmation waits. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (optIn && ctlProject) { | ||
| useProjects.getState().addSeamlessPair(ctlProject.id, pairKey) | ||
| } | ||
| setConfirm(null) | ||
| await deliver() |
|
Good catch, and confirmed: Fixed in dfdad20, but deliberately not by adding the field to So grants now ride the machine-local index entry, exactly like
Docstring and the Settings copy now say "stays on this machine, never in the shared project file" instead of "persisted on the project". Tests mirror the existing exec suite: omitted from the file, ignored when a hostile file claims it, restored through the machine-local round trip, and no key at all when nothing is granted ( |
The hook token authenticates a session, not which node — and three open PRs now rest on itCross-cutting review note, posted on both #112 and #113 because neither can fix this alone and the fix is the same in both. #98 sits on the same ground. The mechanism
So the token proves "some session nodeterm spawned is calling". It proves nothing about which node is calling. Any control-capable agent can put a different node's id in the body and the server has no way to tell. That was tolerable while a forged What it buys in each PR#113 — per-pair trust. #112 — thread ownership, and through it, browser control. The browser router does the right thing: it demands #98 — same ground, smaller blast radius. Scope, stated plainlyAll of this lives inside one OS user. Nothing crosses the user boundary: the hook server is loopback, the browser socket is 0600 in a 0700 dir, and an attacker who already has that uid can read the files directly. The realistic threat is a prompt-injected or misbehaving agent moving laterally to a sibling node — which is precisely the boundary #112's browser scoping and #113's per-pair grants are advertised to enforce. The fixBind the token to the node, and stop reading identity from the body:
That is one change in Two smaller things worth folding in while that seam is open, both from #112: What I verified, and what I did notRead and confirmed in the code: the single token and its identical injection ( Not verified by running: no crafted POST was actually sent, and neither the bind→browser chain nor the grant spoof was reproduced end to end. Both are read off the code, and a multi-account setup with a live browser node is what would settle them. If either of you sees a check I have missed on the path from a forged body to the acting node, I would rather be wrong about this. Credit where due: #112's cross-account resolution genuinely fails closed (duplicate thread ids resolve ambiguous unless one inode proves otherwise), its rollout hardlinking is carefully defended, and #113 fixes a real bug — the 2 s slowloris guard was killing confirmation-gated |
Wires the agentSeamlessWrites setting (added, inert, in the previous commit) into the canvas-control write handler: when it is on, an agent write delivers directly instead of raising the confirmation dialog. This is a BLANKET grant -- it does not depend on which node is calling, so it is unaffected by the hook token authenticating a session rather than a specific node. Finer, per-node-pair trust is deliberately not included here: that would make the caller's asserted node id security-relevant, and the id is currently forgeable by any token holder. It waits on a node-scoped token.
dfdad20 to
4461f06
Compare
|
I reproduced the spoof end to end before acting on it, then split this PR per your note. Reproduction (single OS user, this machine): created a scratch terminal T; using the shared token from the endpoint file, sent a The split:
On the node-scoped token itself: I agree it should be one shared fix the affected PRs rebase onto rather than three partial ones. I'm happy to take that on as its own PR. Two implementation notes from a first read, if useful — the per-node token can't live in the current endpoint file as extra shell vars (node ids contain hyphens, which aren't valid shell identifiers), so a per-session endpoint file is cleaner and needs no shim change; and the SSH leg reads its token from a per-host endpoint file shared by every node on that host, so it needs the same per-node treatment and gets rewritten on each reconnect. Want me to open that PR against |
|
Thanks @austinschneider — the slowloris half of this is a real, reproduced bug and it has landed separately as #158, with credit to you in the commit trailer and the PR body. Two things were added on top of your fix there:
Worth recording for anyone reading this later: the bug is slightly worse than the description says. A late confirmation still delivers — the renderer already holds the request when the socket dies — so the calling agent is told the endpoint was unreachable while the text lands anyway, and may retry. Silent duplicate delivery. On the seamless agent writes half: we're not taking it as a global bypass, but the capability behind it is wanted. It's being redesigned into a per-project agent-messaging model (project.json switch, like Leaving this PR open for now so the discussion has a home. Appreciate the report and the diagnosis — the reproduction was exact. |
…lled by the receive-phase timeout The 2s slowloris guard was armed for the whole request lifetime, not just the receive phase. A destructive /control/ verb parks in the renderer for as long as the user takes to answer the confirmation dialog (up to the desktop's 120s pendingControl bound), so the guard destroyed that socket mid-dialog: the sh shim reported "control endpoint unreachable" (curl exit 52 at ~2019ms) while a late confirm STILL delivered the text. The agent was told nothing happened when it had, and may retry — a silent duplicate delivery. The guard is RAISED, not removed. Dropping it entirely (setTimeout(0)) leaves /control/ bounded only by pendingControl, a bound that lives outside core, so a future core-side handler would inherit an unbounded socket — and it leaves /context-link/ with no bound at all, where a remote read over a wedged ControlMaster can hang forever (ConnectTimeout only covers connect). Pre-fix, the 2s destroy at least unblocked the agent's curl. So both routes hand off to a named CONTROL_CEILING_MS (130s, comfortably above the desktop's 120s, so the handler's own timeout always wins in practice), and /context-link/ additionally races its handler at 30s, answering with the same "Could not read linked context." prose any other read error yields. Carries the fix half of eneskirca#113 by @austinschneider; that PR's seamless-writes setting is being redesigned separately and is deliberately not included. Co-Authored-By: austinschneider <hogenshpogen@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thank you for this — and in particular for the diagnosis in the first half, which was correct and is now fixed on The slowloris half: your bug report landed, your patch was refinedYou were right that The one change from your patch is that the guard is replaced with a higher ceiling rather than disarmed: const CONTROL_CEILING_MS = 130_000The reasoning, from the comment that shipped with it: the 120s bound on a control request lives in Your The messaging half: supersededThe seamless-writes toggle and the per-node-pair grants are the same ground as the agent-messaging design I am implementing now, so I am not going to merge this and have you rebase onto something that is about to move underneath you. Two things from your design I want to call out because they are going in:
Both with credit to you. Closing this as superseded rather than leaving it to rot — but the slowloris find was a genuinely good catch, and if you want to be in the loop on the messaging design before it lands, say so. |
Two commits: a bug fix that makes agent
write/closeconfirmations work at all, and a trust model on top of the working dialog.Bug: the slowloris guard kills confirmation-gated control requests (commit 1)
hook-server.tsarmsreq.setTimeout(SLOWLORIS_MS /* 2s */, () => req.destroy())before routing. For/control/writeand/control/close, the main-process handler forwards to the renderer and legitimately waits up to 120s for the user's confirmation dialog — so the socket is destroyed 2 seconds into that wait. The calling shim sees curl code 000 and prints "Could not reach nodeterm (control endpoint unreachable)" while the dialog is still on screen; with the one-confirm-at-a-time guard, one stuck dialog then bounces every later write. In practice, agent-to-agent messaging only worked if the user clicked within 2 seconds.Fix: disarm the guard (
req.setTimeout(0)) once the request body is fully read, on the/control/and/context-link/routes (a linked-transcript read over SSH can also legitimately exceed 2s). The receive phase — what the guard exists for — is still covered.Feature: seamless agent messaging, global + per-pair (commits 1+2)
With the dialog now functional, pair-programming flows (agent A drives agent B, reviews, sends the next piece) still pay one click per message. This adds pre-approval at two scopes:
Settings → Agents → "Seamless agent messaging", default off): every agent write delivers without the dialog. Documented in the setting's comment as the blunt instrument it is.idA|idBkey on the project (persisted like the kanban board), and writes between that pair — either direction — deliver directly from then on.Design decisions, so review can disagree with them specifically:
deleteNodesprunes every grant referencing a deleted node, so trust can never outlive — or transfer to a later reuse of — a node id.closealways confirms, at both scopes. Only messaging is pre-approvable.setConfirm(never the raw setter), sharing the reply closures so a rebuild can't orphan a pending request.Settings → Agents → "Trusted node pairs"shows a live grant count for the active project with Clear-all. The ConfirmDialogoptionprop already existed (worktree deletion opt-in) and is reused, not duplicated.Validation
npm run typecheckclean;npm run testgreen (4468 passed; one pre-existing flaky filesystem-watch test inboard-log-handlers.test.tsfails occasionally under full-suite load and passes in isolation, unchanged tree included).Happy to split the bug fix into its own PR if you'd rather take it separately, or to adjust the trust model (e.g. per-direction grants, TTLs) to taste.