Skip to content

feat(web): guest shares — join link, ro terminal, guest state (G1e) - #104

Merged
frahlg merged 1 commit into
mainfrom
104-g1e-spa
Aug 30, 2026
Merged

feat(web): guest shares — join link, ro terminal, guest state (G1e)#104
frahlg merged 1 commit into
mainfrom
104-g1e-spa

Conversation

@frahlg

@frahlg frahlg commented Aug 30, 2026

Copy link
Copy Markdown
Member

The SPA side of guest sharing (G1 spec §6 web), phone-first. Part of #55.

The join-link flow as shipped

  1. 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), and afterSignIn branches join- BEFORE the pairing-code replay (source-pinned).
  2. joinWithCode (new web/src/join.js) rides the SAME blind pair room and NNpsk0 initiator as pairing — openPairRoom was extracted from pair.js so 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.
  3. The grant is verified with G1a's grant.js (signature, guest == this identity, machine == the room's machine, window live), then lands as addMachine({..., 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) turns current.send into a property whose setter wraps EVERY assignment: connectOnce reassigns send on each (re)connect, and all keystroke sources — term.onData, the mobile key bar, tmux control — go through current.send, so one guard point covers them all across reconnects. The wrapper drops FRAME_DATA and FRAME_CONTROL before the wire and passes only FRAME_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

  • Machine list: ⇢ sharebox + shared with you · read-only · expires in 42 min — phrasing byte-identical to mir 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.
  • Terminal: topbar chip 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).
  • No share minting in the SPA (Fredrik's approved Q4) — nothing half-built.

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 and send = null teardown), 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 -l empty, go vet clean; 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 new viewJoin / joinWithCode path that reuses the existing /pair room via extracted openPairRoom (5‑minute owner verdict window). The guest sees a safety number, receives a verified signed grant, and persists addMachine({ …, owner }) plus the grant in localStorage.

Attach & input: connectOnce uses machine.owner || signer.address for relay attach so guests register under the machine owner while still authenticating with their binding. Read-only shares get guardReadonlySend at 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; sweepGuestGrants prunes dead grants and guest machine entries on list load. Service worker shell bumps to v6 and precaches guest.js / join.js.

Reviewed by Cursor Bugbot for commit 1f436a4. Bugbot is set up for automated code reviews on this repo. Configure here.

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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T11:41:54.476147Z 1f436a4 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread web/src/app.js
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread web/src/app.js
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f436a4. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a non-blocking comment: Cursor Bugbot did not complete successfully (skipped) and reported 1 potential issue that needs human attention, so this automation is not approving. Reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested review from miravoss26 and wachtelhund August 30, 2026 11:46
@frahlg
frahlg merged commit 0ac0fcf into main Aug 30, 2026
5 checks passed
frahlg added a commit that referenced this pull request Aug 30, 2026
* 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>
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