feat: mir share / mir join — grant bootstrap over the pair room (G1b) - #101
Conversation
Per G1 spec §2: the guest joins the one-time NNpsk0 room as initiator (claims and proves its key with pairing's own msg1/msg3), the machine's info rides the authenticated msg2, the guest presents its signed transport binding, and the OWNER holds the only trust decision — safety number + y/N, default No, no --yes, TTY required. On confirm the grant is minted bound to the proven guest key and installed on the AGENT first over an authenticated session (CONTROL add-grant, acked by a HELLO carrying "ack"), so a guest never holds a grant the machine does not know. Write shares demand typing the machine name. The agent only receives here: verify + session-owner + this-machine + not-expired, then persist under gid. Enforcement is G1c. ControlHandler acks became a map so add-grant can acknowledge without pretending to be a rename; rename's wire behavior is unchanged. Co-Authored-By: Claude Fable 5 <[email protected]> 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: 4c24bf78cd
ℹ️ 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".
| _ = fs.Parse(args) | ||
| if len(fs.Args()) != 1 { | ||
| return fmt.Errorf("usage: mir share <machine> [--ttl 1h] [--write] [--session main]") |
There was a problem hiding this comment.
Accept flags after the machine argument
The documented form is mir share <machine> [--ttl ...], but the standard Go flag parser stops immediately before the first non-flag argument. Consequently, natural invocations such as mir share box --ttl 10m leave --ttl and 10m in fs.Args() and fail with the usage error instead of creating a share. Extract the machine positional before parsing, as cmdMachineRevoke already does, or otherwise support the advertised ordering.
Useful? React with 👍 / 👎.
| expires := time.Now().Add(*ttl) | ||
| fmt.Fprintf(a.out, "Share %q — %s access until %s.\n", m.Name, modeWord(mode), expires.Format("15:04")) |
There was a problem hiding this comment.
Derive the displayed expiry from the minted grant
This computes the advertised expiry before waiting up to five minutes for the guest and before the approval prompt, while MintGrant later sets sg.NA from a fresh time.Now(). A guest joining near the invite deadline therefore receives access for up to five minutes beyond the time shown to the owner, and both the success and disconnect messages repeat that incorrect time. Display time.Unix(sg.NA, 0) once the grant is minted, or base minting on the same timestamp used here.
Useful? React with 👍 / 👎.
| sg, err := identity.ParseSignedGrant(verdict) | ||
| if err == nil { | ||
| err = identity.VerifyGrant(sg) | ||
| } | ||
| if err != nil { | ||
| return fmt.Errorf("the share record did not verify — ask for a new invite (cause: %v)", err) | ||
| } |
There was a problem hiding this comment.
Reject grants that have expired before join completes
When a short --ttl is used or installing the grant consumes most of its lifetime, the grant can expire between the agent's acceptance and the guest receiving it. VerifyGrant validates only the record and signature, so this path still stores the machine and grant and prints ✓ joined even though the resulting access is already unusable. Call sg.ValidAt(time.Now()) before mutating the guest's state.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4c24bf7. Configure here.
| return fmt.Errorf("%q did not accept the share (%v) — it may run an agent from before sharing; run `%s update` on it and mint a new invite. Nothing was shared", m.Name, err, a.binary) | ||
| } | ||
| if err := mc.Send([]byte(record)); err != nil { | ||
| return fmt.Errorf("the machine accepted the share but the guest disconnected — the access expires %s on its own, or revoke id %s once share revoke ships", expires.Format("15:04"), sg.GID) |
There was a problem hiding this comment.
Grant sent in pair-room plaintext
High Severity
After the Noise handshake, cmdShare and cmdJoin send the guest binding and the full signed grant as raw pair-room bytes. Pairing discards the handshake state and never opens a transport cipher, so the relay can read machine_id, owner, guest, mode, and scope. That breaks the project invariant that the relay sees only ciphertext, and it contradicts the claim that nothing about the target rides in the clear.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 4c24bf7. Configure here.
| } | ||
| if err := mc.Send([]byte(record)); err != nil { | ||
| return fmt.Errorf("the machine accepted the share but the guest disconnected — the access expires %s on its own, or revoke id %s once share revoke ships", expires.Format("15:04"), sg.GID) | ||
| } |
There was a problem hiding this comment.
Pair room expires during owner consent
High Severity
Share still needs the pair room after the guest joins: the owner compares the safety number, attaches, then sends the grant. The relay’s pair-bridge TTL is 60 seconds from connect, and the share context is a single 5-minute deadline from start. A normal aloud SAS check, or a slow attach, closes the room so join fails and the agent can be left with an orphan grant.
Reviewed by Cursor Bugbot for commit 4c24bf7. Configure here.
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| defer stop() | ||
| ctx, cancel := context.WithTimeout(ctx, inviteWindow) | ||
| defer cancel() |
There was a problem hiding this comment.
Invite window exceeds pair-room wait
Medium Severity
inviteWindow is 5 minutes and share prints that the guest has 5 minutes to join, but the relay evicts an unpaired /pair waiter after 2 minutes. A guest who arrives in that gap finds no owner, and the owner’s wait fails early despite the printed deadline.
Reviewed by Cursor Bugbot for commit 4c24bf7. Configure here.
| } | ||
| sg, err := identity.MintGrant(signer, m.MachineID, guestWallet, *session, mode, *ttl, time.Now()) | ||
| if err != nil { | ||
| return err |
There was a problem hiding this comment.
Grant flags validated after guest waits
Medium Severity
--ttl and --session are only checked inside MintGrant, after the guest has already joined and the owner has approved. An over-cap TTL or illegal session name aborts with nothing shared, while the guest is still blocked on the pair room until their timeout.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 4c24bf7. 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y --------- Co-authored-by: Claude Fable 5 <[email protected]>




Second build slice of the G1 guest-sharing spec (§2 Bootstrap). Part of #55.
The flow as shipped
mir share <machine> [--ttl 1h] [--write] [--session main]: mints a one-time invite (pairing's token/code/QR +<web>/#join-<code>link), waits as room responder. The machine's identity (host_pub, machine_id, name) travels INSIDE the authenticated msg2 — nothing about the target rides in the clear.mir join <code>: guest identity created on first run, room initiator; the guest claims and proves its key with pairing's own msg1/msg3, then presents its signed transport binding (verified against the claimed key before the owner ever sees a prompt).--yesexists; non-TTY refuses ("sharing needs a person").--writeadditionally requires typing the machine name.add-grantand must be acked (HELLO withack: add-grant:<gid>) before the guest receives anything — a guest never holds a grant the machine does not know. Old agents never ack → honest abort, nothing shared.grants/<gid>.json. Attach enforcement, expiry timers, tombstones are G1c.map[string]string(rename:{name}; add-grant:{name, ack}) — rename wire behavior unchanged, tests updated.Acceptance evidence (hermetic, httptest relay + real agent + real WebRTC attach)
TestShareJoinLiveGrantLandsOnAgent: mint → join → grant file on the agent verifies, names the guest identity, ro/main; guest keeps machine + grant copy.TestShareDeclinedSASPinsNothing: owner answers n → join fails, agent has no grants, guest store empty, copy says "declined — nothing was shared".go test ./...green,gofmt -lempty,go vetclean, web 157/157. No vectors changed.🤖 Generated with Claude Code
https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y
Note
Medium Risk
Touches authenticated CONTROL handling and grant persistence on the agent, but grants are strictly verified and scoped; share flow is gated on interactive owner approval and agent confirmation before the guest gets a record.
Overview
Adds time-boxed guest sharing via
mir share <machine>andmir join <code>, using the existing pair room (QR/link, safety number, guest binding verification). The owner must approve interactively (no--yes; non-TTY refused;--writerequires typing the machine name).Agent learns first: after approval, the owner attaches and sends CONTROL
add-grant; the agent verifies the signed grant (session owner, this machine, not expired), persistsgrants/<gid>.json, and re-HELLOs withack: add-grant:<gid>. The guest only receives the grant record over the pair room if that ack succeeds—old agents that never ack abort the share.Protocol tweak:
ControlHandleracknowledgements are now amap[string]string(rename still sends{name}; add-grant sends{name, ack}), chained with rename viachainControl. Client helpersGrantOverSessionandSaveGuestGrantsupport delivery and local guest storage; attach-time enforcement is left for G1c.Reviewed by Cursor Bugbot for commit 4c24bf7. Bugbot is set up for automated code reviews on this repo. Configure here.