Skip to content

feat(api): a conversation can be shared with a second person, by a token that reaches only it - #509

Merged
brcampidelli merged 1 commit into
mainfrom
feat/a-conversation-shared-by-a-token-that-reaches-only-it
Sep 17, 2026
Merged

brcampidelli merged 1 commit into
mainfrom
feat/a-conversation-shared-by-a-token-that-reaches-only-it

Conversation

@brcampidelli

Copy link
Copy Markdown
Owner

What

The backend half of multiplayer (item 3 of the list audited on 2026-09-16): a coding conversation can be shared with a second person by a token that opens only that conversation. The owner's decision, 2026-09-17: a token per shared session, never the server token. The screens (Share on the conversation, the guest page, presence and author labels) are the next PR; the two defaults the owner did not rule on are taken as stated — LAN only, opt-in, never open at launch, and guests send messages but do not answer governance cards.

How

chimera/api/sharing.py

  • ShareStore — one token per guest (secrets.token_urlsafe(24)), stored in <home>/code_shares.json (same trust boundary as code_sessions/), resolved with a constant-time compare against every stored token, revoked one at a time; deleting a conversation or a project revokes all of theirs.
  • SessionBus — every frame a turn emits is also published on the session's bus: numbered by the session (session_seq), enveloped with turn_id and author, kept in a bounded ring (4,000 frames) for replay from since, delivered to subscribers across event loops with call_soon_threadsafe. The browser picture is delivered and not kept, for the run log's reason. Who is subscribed is the presence list; joining and leaving publish a presence frame that is never in the ring (it is about now). A new subscriber's first live frame is the presence list with themselves on it.
  • lan_addresses() — the IPv4 addresses a machine on the network could use, without sending a packet.

chimera/api/guest_api.py

  • build_guest_app — a separate FastAPI app with four routes, each behind a share token (bearer, or ?t= because EventSource cannot set a header): GET /api/session (the same exchanges the owner's replay shows, with the folder's name and never the owner's path), GET /api/live?since=&name= (replay, then live, heartbeat every 15 s, subscribed for exactly as long as the stream runs), POST /api/turn (runs through the same _start_turn the owner's turn does, on the conversation's own folder, under the request's default seams — the most cautious this surface has — with the guest's name on every frame and on the receipt as author), GET /api/presence. Nothing else answers a share token; there is no route to answer a governance card.
  • Mounted under /guest on the owner's server, and the only thing served by GuestServer — a second uvicorn listener on its own thread, bound and listening before it is announced (the lesson of perf(app): the backend listens before it announces its port — a launch stops paying half a second #506), opened by POST /api/code/share/network, closed by DELETE, reported by GET, never open at launch.
  • Owner routes: mint (POST /api/code/sessions/{id}/share, with the LAN link only when the door is open — a link that goes nowhere is worse than none), list, revoke one, presence, and GET /api/code/sessions/{id}/live — the owner's own window, which is how the owner's screen will see a turn a guest started.

code_api.pycode_turn now delegates to _start_turn(req, *, author=""); emit publishes to the bus; a turn_started frame carries the message and the author before any work (a viewer who did not send the message needs both to draw the row); the receipt gets author when the turn was not the owner's; _session_view is split from the route so the guest app reads the same fold with the same receipts; deleting a conversation or a project revokes its tokens.

docs/security.md — a section, in ten languages: what a share token opens, what the network door serves, and the sentence that must be said before a link is handed out — a guest can ask the agent to do anything the owner can ask it, in the owner's project, with the owner's tools and spend, short of approving cards.

Tests (11)

Store: mint/resolve/revoke/persist, unreadable file starts empty. Bus: numbering per session, replay from a point, pictures out of the ring, bounded ring, delivery from another thread, presence in and out; live_frames replays, announces the viewer, heartbeats, follows, and leaves the presence when the stream ends. Through the app: readable and writable with the token and with nothing else (no token, wrong bearer, wrong query → 401); the guest's turn lands on this conversation with author: "Ana Lima" on its receipt and the owner's replay shows it; the other conversation is untouched and unnameable; the guest app 404s every owner route (/guest/api/config, /doctor, /code/sessions, /code/approve, /code/share/network, …); the bus carries the guest's turn with the author on every frame, numbered without gaps; deleting the conversation revokes its tokens, one token can be revoked alone, and a token of another conversation cannot be revoked through this one's route; a guest turn runs under the request defaults on the session's own folder with a cleaned, capped name; and the network door opens a real listener on 0.0.0.0:0 that serves /api/session with the token, 401s without it, 404s the owner's routes, is the same door when opened twice, and refuses connections after DELETE.

Sabotage: resolve("") made to return the first share → 4 tests fail; receipt["author"] dropped → the author tests fail; both restored. Full gate on a clean copy in WSL: ruff and mypy clean (363 files), 6561 passed, 18 skipped, 10 xfailed in 191 s. OpenAPI + api-schema.ts regenerated (owner routes; the mounted guest app is outside the main schema by design).

Not in this PR

The screens. A guest name is not authenticated — it is a label the guest gives, shown as given. No rate limit on the guest listener. The share token is stored in clear in the owner's home (stated in the module; it is the same trust boundary as the conversations themselves).

🤖 Generated with Claude Code

…ken that reaches only it

The owner's decision: a token per shared session, never the server token.
ShareStore mints one per guest, resolves in constant time, revokes one alone
and all of them when the conversation is deleted. SessionBus publishes every
frame of every turn on the session's own bus — numbered by the session, a
bounded ring for replay, the browser picture live-only — so the owner's screen
and a guest's see a turn whoever started it; who is subscribed is presence.

The guest app knows four routes under /guest/api: read the conversation (the
folder's name, never the path), watch it live, send a message (through the
same _start_turn as the owner's, on the conversation's own folder, under the
request's default seams, the guest's name on every frame and on the receipt
as `author`), see who is there — and answers nothing else, with no route to
answer a governance card. The owner's routes mint, list and revoke tokens,
watch a conversation live, and open/close a second listener on every
interface that serves the guest app and only it, never open at launch.

docs/security.md says what a share token is and is not, in ten languages.
The screens are the next PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@brcampidelli
brcampidelli merged commit 37d5d17 into main Sep 17, 2026
16 checks passed
brcampidelli added a commit that referenced this pull request Sep 17, 2026
…ode screen, the guest's page, and both windows drawing the same turn (#511)

The second half of item 3 ("multiplayer") of the list audited on 2026-09-16, on the backend #509
put in place.

Owner's side: `Share` beside the conversation opens a panel that says first what a link is (a guest
can ask the agent anything the owner can, in this project, with the owner's tools and spend, short
of approving cards), then mints a link with an optional label, copies, revokes one alone, and opens
or closes the network door. A link shows no address while the door is closed. Once the conversation
has a link the window subscribes to the session's live stream: a guest's turn is drawn as it runs
with the guest's name on the exchange, a card a guest's turn raises lands on the owner's screen,
the window's own turns are recognised by id and never drawn twice, a cut stream resumes from the
last frame read after three seconds.

Guest's side: `guest.html`, a second input of the same Vite build, served by the guest listener at
its root and under `/guest` on the owner's — as a plain file, never through the handler that writes
the owner's token into `index.html` (pinned by a test). Deliberately not the app: a name, the
conversation with the folder's name, who is here, a box to ask, the tools each turn used; when the
agent stops to ask the owner, the guest is told they are waiting on the owner. The owner's
subscription name `owner` is shown in the guest's language.

Found live and fixed here: the panel re-listed its links through the query cache, which the app
keeps fresh for thirty seconds — a door opened on a port left every link at "no address", a link
minted just after the list was read did not appear. The test client's cache was never fresh, so the
suite could not see it; the new panel test runs under the app's own thirty seconds and fails
without the fix.

Verified end to end on 2026-09-17 against a scratch backend with the real LAN listener, with #510
in place: the door, both links addressed at once, the guest page over the LAN address, a guest turn
drawn on both windows with the author chip, presence on both sides, the guest named again on reopen.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant