Rooms today have in-process members only: each is bound to a set of workspace documents and answers via retrieval. The larger prize is a member that is a foothold in another environment — a Claude Code session in a different repo, a Codex session on another machine — because that is where the information asymmetry a room depends on is largest. A session holds context that is too large, too live, too private, or too capability-bound to paste into one chat.
Target shape:
you → #api-token-change
Changing the auth token to a JWT. What breaks?
@web 3 call sites assume opaque. src/lib/session.ts:44 parses it as a key.
@mobile We cache in Keychain and never decode — but we pin length at 128.
@infra The ALB caps request headers at 4KB; current p99 is 900 bytes.
Proposed approach: a headless foothold worker
A process run next to a repo that answers a room question by invoking a headless agent CLI (claude -p, codex exec) in that working directory. It captures the foothold — repo, machine, credentials — without needing to interrupt a live session. Reuses AgentWorker and HubClient verbatim, so nothing new goes on the wire.
packages/adapters/src/collab/headless-runtime.ts — HeadlessCliRuntime implements AgentRuntime, injectable spawnFn so it is testable without spawning.
apps/web/scripts/collab-foothold.ts — env reader assembling HubClient + AgentWorker + the runtime.
- Provenance in
AgentTurnResult.meta: cwd, repo.{remote,branch,commit,dirty}, hostname, exitCode, durationMs. Commit and dirty refresh per turn — the claim of a foothold answer is "this is what the code says now".
Safety essentials: spawn(command, [...args, prompt], { shell: false }) so an attacker-influenced question lands as one argv element; scrub COLLAB_SECRET from the child env; maxConcurrent: 1 per working directory; time out below the hub's turnTimeoutMs (120s) so completed work is not discarded.
Blocking: hub security floor
No machine outside the operator's trust boundary should join until this lands. Found while planning; all verified against packages/adapters/src/collab/net/.
nodeId is not in the signed canonical string. signerNodeId is the unsigned x-collab-node header echoed back, and routes compare it to a body field the same caller controls. Any holder of COLLAB_HUB_SECRET can claim any node identity. Fix: protocol v2 binds it into the MAC; accept v1+v2 during rollout.
- No workspace scoping on any hub route. A registered node can read any channel's transcript, append messages under an arbitrary author, and run
start|step|run|pause|resume|takeover|complete on any meeting. Channel.workspaceId and MeetingConfig.workspaceId already exist, so the hub can resolve the owning company without a store-port change — what is missing is node → company, which wants a NodeDirectory port.
- One shared secret for every node. Onboarding an external party means handing it over, and revoking one session rotates everyone's credential. Per-node secrets are what make external participation real.
listKnownNodes binds any connected node to whichever company's settings page loads first (apps/web/src/server/collab/runtime.ts). This is a live cross-tenant admission hole today, independent of this feature, and worth fixing on its own.
GET /collab/v1/health is unauthenticated and returns the node inventory and every meeting id.
Also worth fixing while in there: servesRemotely never checks lastSeenAt, so a dead node is still elected and stalls a round for the full turn timeout; session.queue grows unboundedly for ghost nodes; and NonceCache.evict returns early below 512 entries and then only removes expired ones, so sustained traffic makes every claim an O(n) scan.
Capabilities gate HTTP actions, not content
Worth stating explicitly, because "ask-only" sounds like more containment than it is. A member holding only an answer capability can still end a meeting by emitting MEETING_COMPLETE (the marker is handed to every worker in the turn context), or redirect the floor by writing @mention under moderated/reactive. Neither is an HTTP request.
Rooms as shipped have neither mechanism — no speaker election, no completion marker, and members never see each other's answers — which is a substantive reason the fan-out shape was chosen first. When deliberation returns, proposesCompletion and mention-nomination need gating on a control capability, and buildTranscriptMessages should mark foreign-origin turns as data rather than instructions.
Alternative considered: MCP server
Lets a live session join from inside its own loop and answer with real conversation history — truest to the goal. Costs: this repo has never shipped a bin, packages/features/src/mcp is an export {} stub whose planned direction is outbound (exposing our engine as tools, the opposite of joining a room), and the agent must choose to poll. Worth doing after the foothold proves the value.
Note the existing agent-knowledge connector deliberately refuses to read session transcripts (projects/, sessions/, history.jsonl are all denied, with a written rationale). Scraping local session files is not the path.
Acceptance
- A second repo on another machine answers a room question with no pasting.
- Each answer carries provenance identifying the repo and commit it came from.
- An instruction planted in a README in that repo cannot read
COLLAB_SECRET or drive room control.
- The metric is not answer quality — measurement says that is a wash — but how much context a human did not have to move by hand.
Rooms today have in-process members only: each is bound to a set of workspace documents and answers via retrieval. The larger prize is a member that is a foothold in another environment — a Claude Code session in a different repo, a Codex session on another machine — because that is where the information asymmetry a room depends on is largest. A session holds context that is too large, too live, too private, or too capability-bound to paste into one chat.
Target shape:
Proposed approach: a headless foothold worker
A process run next to a repo that answers a room question by invoking a headless agent CLI (
claude -p,codex exec) in that working directory. It captures the foothold — repo, machine, credentials — without needing to interrupt a live session. ReusesAgentWorkerandHubClientverbatim, so nothing new goes on the wire.packages/adapters/src/collab/headless-runtime.ts—HeadlessCliRuntime implements AgentRuntime, injectablespawnFnso it is testable without spawning.apps/web/scripts/collab-foothold.ts— env reader assemblingHubClient+AgentWorker+ the runtime.AgentTurnResult.meta:cwd,repo.{remote,branch,commit,dirty},hostname,exitCode,durationMs. Commit and dirty refresh per turn — the claim of a foothold answer is "this is what the code says now".Safety essentials:
spawn(command, [...args, prompt], { shell: false })so an attacker-influenced question lands as oneargvelement; scrubCOLLAB_SECRETfrom the child env;maxConcurrent: 1per working directory; time out below the hub'sturnTimeoutMs(120s) so completed work is not discarded.Blocking: hub security floor
No machine outside the operator's trust boundary should join until this lands. Found while planning; all verified against
packages/adapters/src/collab/net/.nodeIdis not in the signed canonical string.signerNodeIdis the unsignedx-collab-nodeheader echoed back, and routes compare it to a body field the same caller controls. Any holder ofCOLLAB_HUB_SECRETcan claim any node identity. Fix: protocol v2 binds it into the MAC; accept v1+v2 during rollout.start|step|run|pause|resume|takeover|completeon any meeting.Channel.workspaceIdandMeetingConfig.workspaceIdalready exist, so the hub can resolve the owning company without a store-port change — what is missing is node → company, which wants aNodeDirectoryport.listKnownNodesbinds any connected node to whichever company's settings page loads first (apps/web/src/server/collab/runtime.ts). This is a live cross-tenant admission hole today, independent of this feature, and worth fixing on its own.GET /collab/v1/healthis unauthenticated and returns the node inventory and every meeting id.Also worth fixing while in there:
servesRemotelynever checkslastSeenAt, so a dead node is still elected and stalls a round for the full turn timeout;session.queuegrows unboundedly for ghost nodes; andNonceCache.evictreturns early below 512 entries and then only removes expired ones, so sustained traffic makes every claim an O(n) scan.Capabilities gate HTTP actions, not content
Worth stating explicitly, because "ask-only" sounds like more containment than it is. A member holding only an
answercapability can still end a meeting by emittingMEETING_COMPLETE(the marker is handed to every worker in the turn context), or redirect the floor by writing@mentionundermoderated/reactive. Neither is an HTTP request.Rooms as shipped have neither mechanism — no speaker election, no completion marker, and members never see each other's answers — which is a substantive reason the fan-out shape was chosen first. When deliberation returns,
proposesCompletionand mention-nomination need gating on a control capability, andbuildTranscriptMessagesshould mark foreign-origin turns as data rather than instructions.Alternative considered: MCP server
Lets a live session join from inside its own loop and answer with real conversation history — truest to the goal. Costs: this repo has never shipped a
bin,packages/features/src/mcpis anexport {}stub whose planned direction is outbound (exposing our engine as tools, the opposite of joining a room), and the agent must choose to poll. Worth doing after the foothold proves the value.Note the existing agent-knowledge connector deliberately refuses to read session transcripts (
projects/,sessions/,history.jsonlare all denied, with a written rationale). Scraping local session files is not the path.Acceptance
COLLAB_SECRETor drive room control.