feat(api): a conversation can be shared with a second person, by a token that reaches only it - #509
Merged
brcampidelli merged 1 commit intoSep 17, 2026
Conversation
…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
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>
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.
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.pyShareStore— one token per guest (secrets.token_urlsafe(24)), stored in<home>/code_shares.json(same trust boundary ascode_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 withturn_idandauthor, kept in a bounded ring (4,000 frames) for replay fromsince, delivered to subscribers across event loops withcall_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 apresenceframe 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.pybuild_guest_app— a separate FastAPI app with four routes, each behind a share token (bearer, or?t=becauseEventSourcecannot 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_turnthe 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 asauthor),GET /api/presence. Nothing else answers a share token; there is no route to answer a governance card./gueston the owner's server, and the only thing served byGuestServer— 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 byPOST /api/code/share/network, closed byDELETE, reported byGET, never open at launch.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, andGET /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.py—code_turnnow delegates to_start_turn(req, *, author="");emitpublishes to the bus; aturn_startedframe 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 getsauthorwhen the turn was not the owner's;_session_viewis 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_framesreplays, 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 withauthor: "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 on0.0.0.0:0that serves/api/sessionwith the token, 401s without it, 404s the owner's routes, is the same door when opened twice, and refuses connections afterDELETE.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.tsregenerated (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