-
Notifications
You must be signed in to change notification settings - Fork 1
feat(web): guest shares — join link, ro terminal, guest state (G1e) #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,8 @@ import { FitAddon } from '@xterm/addon-fit'; | |
| import { listMachines, addMachine } from './store.js'; | ||
| import { fetchMachines, mergeMachines, freshDevices, sealMachineRecord } from './registry.js'; | ||
| import { pairWithCode } from './pair.js'; | ||
| import { joinWithCode } from './join.js'; | ||
| import { saveGuestGrant, guestGrantFor, grantLive, sweepGuestGrants, shareSummary, expiryPhrase, modeWord, guardReadonlySend } from './guest.js'; | ||
| import { confirmPairingSafety, machineAfterConfirmedPairing, pendingPairingConfirmation } from './pairing/confirm.js'; | ||
| import { registerPasskey, signInPasskey, devOwnerKey, passkeySupported, isLocalhost } from './identity.js'; | ||
| import { signBinding, recordJSON } from './identity/binding.js'; | ||
|
|
@@ -107,8 +109,10 @@ export async function connectOnce(machine, term, current, onConnected, onWindows | |
| const signer = signerKey(); | ||
| if (isMachineRevoked(machine.machine_id, signer.address)) throw new Error('machine revoked'); | ||
| // owner_id is the neutral Ed25519 address; the signed binding authorizes this | ||
| // browser's X25519 transport key for the Noise handshake. | ||
| const ownerId = signer.address; | ||
| // browser's X25519 transport key for the Noise handshake. A share routes | ||
| // under the MACHINE OWNER's id (the agent registers there) while the binding | ||
| // still authenticates us — the guest (G1c). | ||
| const ownerId = machine.owner || signer.address; | ||
| const binding = recordJSON(signBinding(signer, deviceID(), bytesToHex(owner.pub), Math.floor(Date.now() / 1000))); | ||
| const diag = { step: 'start', ws: 'init', gather: '', iceConn: '', conn: '', dc: 'init' }; | ||
| window.__diag = diag; | ||
|
|
@@ -426,6 +430,10 @@ function openSession(machine) { | |
| loop: null, | ||
| notify: null, // set by the mounted terminal view (mountGen-guarded) | ||
| }; | ||
| if (machine.owner) { | ||
| const g = guestGrantFor(id); | ||
| if (!g || g.mode !== 'rw') guardReadonlySend(sess.current); // ro share: output only, at the send path | ||
| } | ||
| sess.term.write('[mir] connecting to ' + (machine.name || machine.machine_id) + '…\r\n'); | ||
| sessions.set(id, sess); | ||
| startLoop(sess); | ||
|
|
@@ -639,7 +647,16 @@ function renderMachines(root, machines, fresh) { | |
| if (!machines.length) { mount(root, emptyMachinesView(root)); return; } | ||
| const viewEl = el('div', { className: 'view' }); | ||
| const grid = el('div', { className: 'grid' }); | ||
| const allShared = machines.every((m) => m.owner); | ||
| for (const m of machines) { | ||
| if (m.owner) { | ||
| // A share someone gave this identity: the grant, not the registry, says | ||
| // what it is — and it carries no owner affordances (no rename/retire). | ||
| grid.append(el('button', { className: 'card machine shared', onclick: () => viewTerminal(root, m) }, | ||
| el('div', { className: 'name' }, '⇢ ' + (m.name || m.machine_id)), | ||
| el('div', { className: 'sub' }, shareSummary(guestGrantFor(m.machine_id))))); | ||
| continue; | ||
| } | ||
| // A machine that is warm in the session pool (R2) shows its live state on | ||
| // the card — tapping it switches back in place, scrollback intact. | ||
| const warm = sessions.get(m.machine_id); | ||
|
|
@@ -656,8 +673,10 @@ function renderMachines(root, machines, fresh) { | |
| grid.append(el('button', { className: 'card add', onclick: () => viewPair(root) }, | ||
| el('div', { className: 'plus' }, '+'), el('div', { className: 'sub' }, 'Pair a machine'))); | ||
| const kids = [ | ||
| el('h1', {}, 'your machines'), | ||
| el('p', { className: 'muted' }, 'Your live terminals. Leave one device, continue on another.'), | ||
| el('h1', {}, allShared ? 'shared with you' : 'your machines'), | ||
| el('p', { className: 'muted' }, allShared | ||
| ? 'Terminals people shared with you. Each expires on its own.' | ||
| : 'Your live terminals. Leave one device, continue on another.'), | ||
| ...retiredNotice(), | ||
| ]; | ||
| if (fresh && fresh.length) { | ||
|
|
@@ -677,6 +696,11 @@ function renderMachines(root, machines, fresh) { | |
| // best-effort: a failure just leaves the local list. Discovery only. When the | ||
| // resulting list is empty, pollForMachine keeps refreshing it live (U3). | ||
| function viewMachines(root) { | ||
| // 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sweep crashes on expired sharesHigh Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 1f436a4. Configure here. |
||
| } | ||
| let localRevocations; | ||
| try { localRevocations = loadRevocations(signerKey().address); } | ||
| catch (e) { | ||
|
|
@@ -832,7 +856,48 @@ function viewPair(root, prefill = '', auto = false) { | |
| el('button', { className: 'link back', onclick: () => leaveScanner(() => viewMachines(root)) }, '← machines'))); | ||
| } | ||
|
|
||
| // viewJoin claims a share invite (the /#join-<code> link): run the guest | ||
| // ceremony, show the safety number to read aloud (the owner approves on their | ||
| // side), and land the share as a guest entry. | ||
| function viewJoin(root, code) { | ||
| const status = el('div', { className: 'status' }); | ||
| mount(root, el('div', { className: 'view' }, | ||
| el('h1', {}, 'joining a shared terminal…'), status)); | ||
| status.textContent = 'connecting to the invite…'; | ||
| const signer = signerKey(); | ||
| const binding = recordJSON(signBinding(signer, deviceID(), bytesToHex(ownerKey().pub), Math.floor(Date.now() / 1000))); | ||
| joinWithCode(code, signer, binding, (sas) => { | ||
| status.innerHTML = ''; | ||
| window.__lastSafety = sas; | ||
| status.append( | ||
| el('div', { className: 'ok' }, 'Read this safety number aloud to the person sharing:'), | ||
| el('div', { className: 'sas' }, sas), | ||
| el('div', { className: 'muted' }, 'They compare and approve on their side — nothing else to do here.')); | ||
| }).then(({ machine, grant }) => { | ||
| addMachine(machine); | ||
| saveGuestGrant(grant); | ||
| status.innerHTML = ''; | ||
| status.append( | ||
| el('div', { className: 'ok' }, '✓ joined ' + (machine.name || machine.machine_id) + ' — ' + modeWord(grant.mode) + ', ' + expiryPhrase(grant.na)), | ||
| el('div', { className: 'actions' }, | ||
| el('button', { className: 'btn', onclick: () => viewTerminal(root, machine) }, 'Open the terminal'), | ||
| el('button', { className: 'link', onclick: () => viewMachines(root) }, 'Done'))); | ||
| }).catch((e) => { | ||
| status.innerHTML = ''; | ||
| status.append( | ||
| el('div', { className: 'muted' }, (e && e.message) || String(e)), | ||
| el('button', { className: 'link', onclick: () => viewMachines(root) }, '← machines')); | ||
| }); | ||
| } | ||
|
|
||
| function viewTerminal(root, machineToOpen) { | ||
| // A share is checked against its own clock first: an expired grant would only | ||
| // earn the agent's silent refusal, which reads as "offline". | ||
| if (machineToOpen.owner && !grantLive(guestGrantFor(machineToOpen.machine_id))) { | ||
| noticeSheet('Your share of ' + (machineToOpen.name || machineToOpen.machine_id) + ' has ended — ask the owner for a new invite.'); | ||
| viewMachines(root); | ||
| return; | ||
| } | ||
| // The view is a SHELL over the warm session pool: machineToOpen becomes the | ||
| // active session (joining the pool — possibly evicting the LRU background | ||
| // machine); every pooled machine keeps its terminal alive in the DOM, hidden | ||
|
|
@@ -879,13 +944,14 @@ function viewTerminal(root, machineToOpen) { | |
| const sw = el('button', { className: 'tb-btn', title: 'switch machine', onclick: () => openSwitcher() }, '⇄'); | ||
| const revokeBtn = el('button', { className: 'tb-btn', title: 'retire machine', onclick: retire }, '⊘'); | ||
| const titleEl = el('div', { className: 'tb-title' }, m().name || m().machine_id); | ||
| const roChip = el('span', { className: 'tb-ro', hidden: true }); | ||
| const renameBtn = el('button', { className: 'tb-btn', title: 'rename machine', onclick: () => renameMachineUI() }, '✎'); | ||
| // machbar: one chip per warm machine (name + state dot), shown only when two | ||
| // or more are pooled — a single machine keeps today's clean layout. | ||
| const machbar = el('div', { className: 'machbar', hidden: true }); | ||
| const strip = el('div', { className: 'winbar' }); | ||
| const view = el('div', { className: 'view term' }, | ||
| el('div', { className: 'topbar' }, back, titleEl, renameBtn, sw, revokeBtn), | ||
| el('div', { className: 'topbar' }, back, titleEl, roChip, renameBtn, sw, revokeBtn), | ||
| machbar, strip, termHost); | ||
| mount(root, view); | ||
| const viewGen = mountGen; // this mount's token: stale session notifies no-op | ||
|
|
@@ -1076,7 +1142,19 @@ function viewTerminal(root, machineToOpen) { | |
| }; | ||
|
|
||
| const renderTitle = () => { const mm = m(); titleEl.textContent = mm.name || mm.machine_id; }; | ||
| function renderChrome() { renderTitle(); renderPill(); renderMachbar(); renderStrip(); } | ||
| // syncGuestChrome: a share carries no owner affordances; a read-only share | ||
| // says so, with its clock, right in the topbar. | ||
| function syncGuestChrome() { | ||
| const mm = m(); | ||
| const isGuest = !!mm.owner; | ||
| renameBtn.hidden = isGuest; | ||
| revokeBtn.hidden = isGuest; | ||
| const g = isGuest ? guestGrantFor(mm.machine_id) : null; | ||
| const ro = g && g.mode !== 'rw'; | ||
| roChip.hidden = !ro; | ||
| if (ro) roChip.textContent = 'read-only · ' + expiryPhrase(g.na); | ||
| } | ||
| function renderChrome() { renderTitle(); renderPill(); renderMachbar(); renderStrip(); syncGuestChrome(); } | ||
|
|
||
| // Every pooled terminal lives in the DOM, hidden except the active one — the | ||
| // durable-terminal design across machines: scrollback survives switching. | ||
|
|
@@ -1134,7 +1212,8 @@ function viewTerminal(root, machineToOpen) { | |
|
|
||
| // after sign-in: replay a scanned pairing code, else show machines | ||
| function afterSignIn(root, pendingFrag) { | ||
| if (pendingFrag) viewPair(root, pendingFrag, true); | ||
| if (pendingFrag && pendingFrag.startsWith('join-')) viewJoin(root, pendingFrag.slice(5)); | ||
| else if (pendingFrag) viewPair(root, pendingFrag, true); | ||
| else viewMachines(root); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| // web/src/guest.js — the guest side of sharing in the SPA (G1e): the local | ||
| // grant store, the shared expiry phrasing, and the read-only send guard. | ||
| // Mirrors the CLI's client/shares.go where the shapes overlap. | ||
| import { FRAME_RESIZE } from './noise/frame.js'; | ||
| import { verifyGrant, validAt } from './identity/grant.js'; | ||
|
|
||
| const KEY = 'tr_guest_grants'; | ||
| const SKEW_S = 300; // matches identity.GrantSkew | ||
|
|
||
| function readAll() { | ||
| try { | ||
| return JSON.parse(localStorage.getItem(KEY) || '{}'); | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
|
|
||
| function writeAll(map) { | ||
| try { | ||
| localStorage.setItem(KEY, JSON.stringify(map)); | ||
| } catch {} | ||
| } | ||
|
|
||
| // saveGuestGrant stores a verified grant record (the caller verified it). | ||
| export function saveGuestGrant(sg) { | ||
| const map = readAll(); | ||
| map[sg.gid] = sg; | ||
| writeAll(map); | ||
| } | ||
|
|
||
| export function listGuestGrants() { | ||
| return Object.values(readAll()).sort((a, b) => b.na - a.na); | ||
| } | ||
|
|
||
| // guestGrantFor returns the latest-expiring grant covering machineID, or null. | ||
| export function guestGrantFor(machineID) { | ||
| for (const g of listGuestGrants()) { | ||
| if (g.machine === machineID) return g; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // grantLive reports whether a grant's window covers now and it still verifies. | ||
| export function grantLive(g, nowSec = Math.floor(Date.now() / 1000)) { | ||
| return !!g && verifyGrant(g) && validAt(g, nowSec); | ||
| } | ||
|
|
||
| // sweepGuestGrants drops grants whose window has fully closed (past na + skew) | ||
| // and returns the machine ids left with no grant at all — the caller removes | ||
| // those machine entries, exactly like the CLI's SweepGuestState. | ||
| export function sweepGuestGrants(nowSec = Math.floor(Date.now() / 1000)) { | ||
| const map = readAll(); | ||
| const hadMachine = new Set(); | ||
| const liveMachine = new Set(); | ||
| for (const [gid, g] of Object.entries(map)) { | ||
| hadMachine.add(g.machine); | ||
| if (g.na < nowSec - SKEW_S) delete map[gid]; | ||
| else liveMachine.add(g.machine); | ||
| } | ||
| writeAll(map); | ||
| return [...hadMachine].filter((m) => !liveMachine.has(m)); | ||
| } | ||
|
|
||
| // expiryPhrase matches the CLI's wording exactly ("expires in 42 min"). | ||
| export function expiryPhrase(na, nowSec = Math.floor(Date.now() / 1000)) { | ||
| const left = na - nowSec; | ||
| if (left <= 0) return 'expired'; | ||
| if (left < 60) return 'expires in under a minute'; | ||
| if (left < 3600) return `expires in ${Math.floor(left / 60)} min`; | ||
| return `expires in ${Math.floor(left / 3600)}h ${String(Math.floor(left / 60) % 60).padStart(2, '0')}min`; | ||
| } | ||
|
|
||
| export function modeWord(mode) { | ||
| return mode === 'rw' ? 'read-write' : 'read-only'; | ||
| } | ||
|
|
||
| // shareSummary is the one line a share renders under its name — identical to | ||
| // the CLI's `mir ls` phrasing. | ||
| export function shareSummary(grant) { | ||
| if (!grant) return 'shared with you'; | ||
| return `shared with you · ${modeWord(grant.mode)} · ${expiryPhrase(grant.na)}`; | ||
| } | ||
|
|
||
| // guardReadonlySend makes a session's send path drop everything except RESIZE | ||
| // before it reaches the wire. connectOnce assigns current.send on every | ||
| // (re)connect, so the guard is a property setter: every assignment flows | ||
| // through it, and no keystroke source (term.onData, the key bar, tmux control) | ||
| // can bypass it — they all call current.send. The agent drops guest input | ||
| // anyway (G1c); this keeps the honest client from even sending it. | ||
| export function guardReadonlySend(current) { | ||
| let inner = current.send || null; | ||
| Object.defineProperty(current, 'send', { | ||
| get() { | ||
| if (!inner) return inner; | ||
| return (framed) => { | ||
| if (framed && framed[0] !== FRAME_RESIZE) return; // ro: output only | ||
| inner(framed); | ||
| }; | ||
| }, | ||
| set(fn) { | ||
| inner = fn; | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| // web/src/join.js — claim a share invite from the browser (G1e). Mirrors the | ||
| // CLI's `mir join`: the guest rides the same blind pair room as pairing, proves | ||
| // its own key with pairing's msg1/msg3, presents its transport binding, shows | ||
| // the safety number (the OWNER holds the y/N on their side), and waits for the | ||
| // signed grant. Crypto is pairing's and grant.js's — nothing new. | ||
| import { startInitiator } from './pairing/nnpsk0.js'; | ||
| import { decodeCode } from './pairing/code.js'; | ||
| import { safetyNumber } from './pairing/sas.js'; | ||
| import { openPairRoom } from './pair.js'; | ||
| import { verifyGrant, validAt } from './identity/grant.js'; | ||
|
|
||
| // The owner is a human deciding on a safety number: give them the same window | ||
| // the CLI invite has (5 min), not pairing's 30 s transport ceiling. | ||
| const JOIN_VERDICT_MS = 5 * 60 * 1000; | ||
|
|
||
| // joinWithCode runs the guest ceremony. `bindingRecord` is this browser's | ||
| // signed transport binding (the same record every attach presents), built by | ||
| // the caller. onSafety(sas) fires as soon as the number is comparable — before | ||
| // the verdict wait — so the guest can read it aloud. Returns | ||
| // { machine, grant } with machine carrying the owner for attach routing. | ||
| export async function joinWithCode(code, signer, bindingRecord, onSafety) { | ||
| const { signalURL, token } = decodeCode(code); | ||
| const room = await openPairRoom(signalURL, token, JOIN_VERDICT_MS); | ||
| try { | ||
| const started = await startInitiator(room.mc, token, signer); | ||
| if (onSafety) onSafety(safetyNumber(started.binding)); | ||
| // The guest risks nothing by proceeding — the owner decides. Prove our key | ||
| // (msg3), present the binding, then wait for the verdict. | ||
| await started.finish(null); | ||
| room.mc.send(new TextEncoder().encode(bindingRecord)); | ||
|
|
||
| let verdict; | ||
| try { | ||
| verdict = await room.mc.recv(); | ||
| } catch { | ||
| throw new Error('the invite was declined or expired — nothing was set up'); | ||
| } | ||
| let grant; | ||
| try { | ||
| grant = JSON.parse(new TextDecoder().decode(verdict)); | ||
| } catch { | ||
| throw new Error('the share record did not verify — ask for a new invite'); | ||
| } | ||
| if (!verifyGrant(grant)) { | ||
| throw new Error('the share record did not verify — ask for a new invite'); | ||
| } | ||
| if (grant.guest !== signer.address || grant.machine !== started.info.machine_id) { | ||
| throw new Error('the share was minted for a different device or machine — ask for a new invite'); | ||
| } | ||
| if (!validAt(grant, Math.floor(Date.now() / 1000))) { | ||
| throw new Error('this share has already ended — ask for a new invite'); | ||
| } | ||
| return { | ||
| machine: { | ||
| machine_id: started.info.machine_id, | ||
| host_pub: started.info.host_pub, | ||
| name: started.info.name, | ||
| signal: signalURL, | ||
| owner: grant.owner, // attach routes under the machine owner; we authenticate as the guest | ||
| }, | ||
| grant, | ||
| }; | ||
| } finally { | ||
| room.close(); | ||
| } | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once a guest grant is more than five minutes past expiry,
sweepGuestGrants()returns its machine ID and this branch callsremoveMachine, butapp.jsimports onlylistMachinesandaddMachinefromstore.js. The resultingReferenceErroraborts every machine-list render, including the post-login path, and persists until the expired local state is manually cleared; import the existingremoveMachineexport before invoking it.Useful? React with 👍 / 👎.