feat(web): guest shares — join link, ro terminal, guest state (G1e) - #104
Conversation
Per G1 spec §6 web. The /#join-<code> link opens the guest ceremony: the same blind pair room and NNpsk0 initiator as pairing (openPairRoom extracted from pair.js, no crypto forked), the guest proves its key and presents its transport binding, shows the safety number to read aloud (the owner approves on their side, with the CLI invite's 5-minute window), then the verified grant lands as a guest-flagged machine. A share attaches through the guest path (owner routing mirrors G1c's Machine.Owner), renders ⇢ with the CLI-identical summary line, and carries no owner affordances — no rename, no retire, no share. A read-only share is guarded at the send path: current.send becomes a property whose every (re)assignment wraps the sender to drop DATA and CONTROL frames before the wire — no keystroke source can bypass it. The topbar says "read-only · expires in 42 min"; an expired share gets the honest line before any dial, and closed shares age out of the local store exactly like the CLI sweep. No share minting in the SPA in v1 (approved decision — phone minting is a fast follow). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1f436a42b1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Shares whose window has fully closed age out here, like the CLI sweep. | ||
| for (const gone of sweepGuestGrants()) { | ||
| const entry = listMachines().find((x) => x.machine_id === gone); | ||
| if (entry && entry.owner) removeMachine(gone); |
There was a problem hiding this comment.
Import removeMachine before sweeping expired shares
Once a guest grant is more than five minutes past expiry, sweepGuestGrants() returns its machine ID and this branch calls removeMachine, but app.js imports only listMachines and addMachine from store.js. The resulting ReferenceError aborts every machine-list render, including the post-login path, and persists until the expired local state is manually cleared; import the existing removeMachine export before invoking it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1f436a4. Configure here.
| // Shares whose window has fully closed age out here, like the CLI sweep. | ||
| for (const gone of sweepGuestGrants()) { | ||
| const entry = listMachines().find((x) => x.machine_id === gone); | ||
| if (entry && entry.owner) removeMachine(gone); |
There was a problem hiding this comment.
Sweep crashes on expired shares
High Severity
viewMachines calls removeMachine after sweepGuestGrants, but store.js is imported only for listMachines and addMachine. There is no linter or typecheck on this app, so the first aged-out share throws ReferenceError and the machine list never mounts. sweepGuestGrants already persisted the grant deletion before the throw, so a reload no longer returns that machine id and the share card stays with no retire control.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1f436a4. Configure here.
* assets: demo GIF for the reach-layer flow The old demo told a story the software no longer tells: a `LAN-direct on (mDNS + QUIC)` line for a transport deleted in #92, `wallet …` for what v0.7 renamed to identity, and a `mir up → mir list → mir attach` flow that predates the overview (#97), the first-run pairing QR, the tmux-style aliases, and session sharing (#101–#104). The new take is the current one, in four beats: bare `mir` opens the live overview; Enter attaches and the tmux session with a long-running agent is right there; Ctrl-O d comes back, and the row now remembers what runs over there; then one invite, read-only, gone in an hour. Every line the script prints is a line `mir` prints. The overview screen is overviewModel.Render()'s output, the attach banner is overview.go's, and the whole `mir share` block — header, QR, both invite lines, the wait line — was captured verbatim from a real run against relay.sourceful-labs.net. The QR in the rendered GIF decodes to the join URL printed beside it. Only the tmux screen and the shell prompt are authored, and neither is mir's output. The canvas grew to 1100x880 (43 rows x 102 cols) so the invite QR fits on one screen without scrolling. 219 KB, 12.8 s. Part of #106 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y * assets: narrow the demo canvas so every cell reads bigger 1100x880 at 102 columns left the 📱 in the mint header about 8 px wide once GitHub scaled the GIF into its 900px column, small enough to read as a box. No font fixes that: `fc-list ':charset=1F4F1'` finds exactly one face on this machine, Apple Color Emoji, and a 10x crop shows that is already the glyph being drawn — bezel, dark screen, colored icons. It is Apple's black phone on a dark theme, not a tofu box. Naming a font in the tape only makes it worse: every installed monospace face falls back to the same Apple glyph, and a comma list starting with JetBrains Mono breaks the cell metrics outright, because vhs supplies that face as a webfont and the list form defeats it. So the lever is columns, not fonts. 884x878 gives 42 rows x 80 columns: the last beat needs 41 rows, and 80 is the narrowest width that still wraps each invite URL to two lines rather than three. Every cell — the emoji included — grows about a quarter, and the frame stops being mostly empty. Widths must stay even. 883 defeated the palette pass and turned the same recording into 14 MB; 884 renders it at 214 KB. 214 KB, 12.8 s, 884x878. The QR still decodes to the join URL printed beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>




The SPA side of guest sharing (G1 spec §6 web), phone-first. Part of #55.
The join-link flow as shipped
term.sourceful-labs.net/#join-<code>→ the fragment is stashed through sign-in (a guest is a passkey user like anyone — the existing identity gate runs first), andafterSignInbranchesjoin-BEFORE the pairing-code replay (source-pinned).joinWithCode(newweb/src/join.js) rides the SAME blind pair room and NNpsk0 initiator as pairing —openPairRoomwas extracted frompair.jsso both ceremonies share one transport scaffold; zero crypto forked. The guest proves its key with pairing's msg1/msg3, sends its signed transport binding, and the view shows the safety number: "Read this safety number aloud to the person sharing — they compare and approve on their side." The verdict wait uses the CLI invite's 5-minute window, not pairing's 30 s transport ceiling.grant.js(signature, guest == this identity, machine == the room's machine, window live), then lands asaddMachine({..., owner: grant.owner})+ the grant in the local store → "✓ joined sharebox — read-only, expires in 58 min" with Open the terminal.How ro input suppression works
guardReadonlySend(current)turnscurrent.sendinto a property whose setter wraps EVERY assignment:connectOncereassignssendon each (re)connect, and all keystroke sources —term.onData, the mobile key bar, tmux control — go throughcurrent.send, so one guard point covers them all across reconnects. The wrapper dropsFRAME_DATAandFRAME_CONTROLbefore the wire and passes onlyFRAME_RESIZE. (The agent drops guest input anyway per G1c — the honest client just never sends it.) Applied at session creation for any share whose grant is not rw.Guest state UX
⇢ sharebox+shared with you · read-only · expires in 42 min— phrasing byte-identical tomir ls(test-pinned); share cards have NO retire link; a list that is ALL shares gets the guest heading ("shared with you — each expires on its own") instead of the owner copy + install push.read-only · expires in 42 min; rename ✎ and retire ⊘ hidden for shares; an expired share gets "Your share of X has ended — ask the owner for a new invite" before any dial; closed shares age out on list render exactly like the CLI sweep (grant files + machine entry).Tests — 10 new in
web/test/guest.test.js: CLI-identical expiry/summary phrasing, grant store + pick-latest + sweep table,grantLive(verify + window + tamper), the send guard (data/control dropped, resize passes, guard survives reconnect reassignment andsend = nullteardown), and source pins: join- routes before pair replay, owner routing in connectOnce, ro guard at openSession, share card affordance-free, guest chrome hides rename/retire. Full web suite 167/167;go test ./...green,gofmt -lempty,go vetclean; sw precache + shell cache v5→v6; no new deps, no bare imports added, no vector changes. G1b/G1c/G1d E2E suites stay green.🤖 Generated with Claude Code
https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y
Note
Medium Risk
Changes attach routing, grant verification, and terminal send-path behavior for shared sessions—security-sensitive—but reuses existing pairing/grant crypto and adds targeted tests plus read-only guards.
Overview
Adds guest share consumption to the Miranda web SPA: invite links (
/#join-<code>), local grant storage, and UX that mirrors the CLI without minting shares in the browser.Join flow: After sign-in,
join-URL fragments route to a newviewJoin/joinWithCodepath that reuses the existing/pairroom via extractedopenPairRoom(5‑minute owner verdict window). The guest sees a safety number, receives a verified signed grant, and persistsaddMachine({ …, owner })plus the grant inlocalStorage.Attach & input:
connectOnceusesmachine.owner || signer.addressfor relay attach so guests register under the machine owner while still authenticating with their binding. Read-only shares getguardReadonlySendat session open (drops terminal data/control frames, keeps resize across reconnects).UI: Shared machines render as distinct cards with CLI-matched expiry copy; owner rename/retire controls are hidden on shares; read-only sessions show a topbar chip. Expired grants block connect with a clear notice;
sweepGuestGrantsprunes dead grants and guest machine entries on list load. Service worker shell bumps to v6 and precachesguest.js/join.js.Reviewed by Cursor Bugbot for commit 1f436a4. Bugbot is set up for automated code reviews on this repo. Configure here.