Skip to content

Rooms: let external coding sessions join as members #342

Description

@Deodat-Lawson

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.tsHeadlessCliRuntime 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/.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions