From 043c103e672c1bcce67df2fcb018ba0c3122b5e1 Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Fri, 24 Jul 2026 15:26:13 +0300 Subject: [PATCH 1/7] feat: gate shared-session access with a per-session share code Sharing now generates a secret code (only its hash is stored). The owner can join their own session from any device without a code, but any other viewer must present the matching code to get a viewer ticket, and non-owner attempts are rate limited per user and session to stop guessing. Unsharing clears the code and revokes outstanding access. The host prints the code and join command on share; `wrapper attach` gains a `--code` option. --- apps/cli/commands/attach.ts | 12 ++-- apps/cli/commands/shell-host.ts | 68 ++++++++++++++++----- apps/cli/index.ts | 2 + packages/backend/convex/lib/relayTicket.ts | 13 ++++ packages/backend/convex/relay.ts | 47 ++++++++++++-- packages/backend/convex/schema.ts | 3 + packages/backend/convex/session.ts | 55 +++++++++++++++++ packages/backend/tests/relay-ticket.test.ts | 22 +++++++ 8 files changed, 197 insertions(+), 25 deletions(-) diff --git a/apps/cli/commands/attach.ts b/apps/cli/commands/attach.ts index b8c6dca..279e09c 100644 --- a/apps/cli/commands/attach.ts +++ b/apps/cli/commands/attach.ts @@ -32,6 +32,7 @@ type AuthorizeAttachResponse = { type IssueViewerTicketArgs = { sessionId: string; + code?: string; }; type IssueViewerTicketResponse = { @@ -69,6 +70,7 @@ export interface AttachOptions { port?: number; host?: string; relay?: boolean; + code?: string; } const SIGINT_EXIT = 130; @@ -82,6 +84,7 @@ export async function runAttach(opts: AttachOptions): Promise { host, target, preferRelay: Boolean(opts.relay), + code: opts.code, }); if (!url) process.exit(1); // Relay URLs carry a single-use join ticket in the query string. Redact it so @@ -300,6 +303,7 @@ async function resolveAttachUrl(input: { host: string; target: TargetSession; preferRelay: boolean; + code?: string; }): Promise { if (!input.preferRelay && input.target.local && input.target.port !== undefined) { const allowed = await ensureAttachAllowed(input.target); @@ -311,10 +315,10 @@ async function resolveAttachUrl(input: { process.stderr.write("[wrapper] relay attach requires `--id `.\n"); return null; } - return await resolveRelayAttachUrl(input.target.id); + return await resolveRelayAttachUrl(input.target.id, input.code); } -async function resolveRelayAttachUrl(sessionId: string): Promise { +async function resolveRelayAttachUrl(sessionId: string, code?: string): Promise { const backend = await resolveAuthedConvexClient(); if (backend.status === "unconfigured") { process.stderr.write("[wrapper] relay attach requires WRAPPER_CONVEX_URL configuration.\n"); @@ -330,7 +334,7 @@ async function resolveRelayAttachUrl(sessionId: string): Promise } try { - const issued = await backend.client.mutation(issueViewerRelayTicketRef, { sessionId }); + const issued = await backend.client.mutation(issueViewerRelayTicketRef, { sessionId, code }); return buildRelayWsUrl(env.relayUrl, issued.ticket); } catch (error) { const message = normalizeAttachAuthorizationError(error); @@ -347,7 +351,7 @@ function normalizeAttachAuthorizationError(error: unknown): string { case "UNAUTHORIZED": return "Not signed in. Run `wrapper auth login` and try again."; case "INSUFFICIENT_PERMISSION": - return "Access denied. Ask session owner to share it, or attach to your own session."; + return "Access denied. Ask the session owner for a share code and pass it with `--code `, or attach to your own session."; case "RESOURCE_NOT_FOUND": return "Session not found or no longer active."; default: diff --git a/apps/cli/commands/shell-host.ts b/apps/cli/commands/shell-host.ts index dc1be7c..67167e8 100644 --- a/apps/cli/commands/shell-host.ts +++ b/apps/cli/commands/shell-host.ts @@ -91,6 +91,30 @@ const createProCheckoutRef = makeFunctionReference< { successUrl?: string }, { checkoutUrl: string } >("billing:createProCheckout"); +const setShareCodeRef = makeFunctionReference< + "mutation", + { sessionId: string; code?: string }, + { ok: boolean; shared: boolean } +>("session:setShareCode"); + +// Crockford-style alphabet (no I, L, O, U) so codes are easy to read and type. +const SHARE_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** + * Generate a short, human-friendly share code. The owner gives this to whoever + * they want to let in; the backend stores only its hash. Displayed grouped + * (XXXX-XXXX) but the backend ignores the dash and case when matching. + */ +function generateShareCode(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + let code = ""; + for (let i = 0; i < bytes.length; i++) { + if (i === 4) code += "-"; + code += SHARE_CODE_ALPHABET[bytes[i]! % SHARE_CODE_ALPHABET.length]; + } + return code; +} export async function runShellHost(opts: ShellHostOptions = {}): Promise { // Guard against recursive shell-host re-entry. @@ -189,11 +213,25 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { } let shared = false; + let shareCode: string | null = null; let relayBridge: RelayHostBridge | null = null; // Guards against a second `share` press racing the in-flight relay setup // (we no longer optimistically flip `shared` to serve as that guard). let relayStarting = false; const sessionTag = sessionId.slice(0, 6); + + function printShareInvite(): void { + if (!shareCode) return; + const lines = [ + `share code: ${shareCode}`, + `others join with: wrapper attach --relay --id ${sessionId} --code ${shareCode}`, + ]; + if (session.isIdle) { + for (const line of lines) inlineMessage(line); + } else { + for (const line of lines) log.info(line); + } + } const heartbeat = setInterval(() => { if (backend.status !== "ready") return; void backend.client @@ -248,15 +286,12 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { } relayStarting = true; + const code = generateShareCode(); try { - // Persist the shared flag before issuing the relay ticket so backend - // state (e.g. viewer authorization checks) is synchronized rather than - // racing the periodic fire-and-forget heartbeat. - await backend.client.mutation(sessionHeartbeatRef, { - sessionId, - shared: true, - port: server.port, - }); + // Persist the shared flag and the access-code hash before issuing the relay + // ticket so viewer authorization is synchronized rather than racing the + // periodic fire-and-forget heartbeat. Only the code hash is stored. + await backend.client.mutation(setShareCodeRef, { sessionId, code }); await backend.client.mutation(setRelayStateRef, { sessionId, relayState: "connecting" }); const issued = await backend.client.action(issueHostRelayTicketRef, { sessionId }); relayBridge = startRelayHostBridge({ @@ -284,8 +319,10 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { .catch(() => {}); }, }); + shareCode = code; commitShared(); announce(`wrapper • shared • ${sessionTag}`, "session shared via relay"); + printShareInvite(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); await backend.client @@ -298,9 +335,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { // heartbeat we sent before the ticket, and show a clean upgrade prompt // (with a retry hint) instead of dumping the raw server error. log.debug("relay share denied (Pro required)", { error: err.message }); - await backend.client - .mutation(sessionHeartbeatRef, { sessionId, shared: false, port: server.port }) - .catch(() => {}); + await backend.client.mutation(setShareCodeRef, { sessionId }).catch(() => {}); if (env.hudEnabled) setTitle(""); const checkoutUrl = await fetchProCheckoutUrl(backend.client); @@ -366,12 +401,13 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { return; } shared = false; + shareCode = null; setSessionShared(sessionId, false); trackEvent("session_unshared"); if (backend.status === "ready") { - void backend.client - .mutation(sessionHeartbeatRef, { sessionId, shared: false, port: server.port }) - .catch(() => {}); + // Clears both `shared` and the stored code hash, revoking any + // outstanding viewer access immediately. + void backend.client.mutation(setShareCodeRef, { sessionId }).catch(() => {}); } void stopRelayBridge(); announce("", "session unshared"); @@ -379,7 +415,9 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { case "status": announce( shared ? `wrapper • shared • ${sessionTag}` : `wrapper • idle • ${sessionTag}`, - `id=${sessionTag} port=${server.port} shared=${shared ? "yes" : "no"}`, + `id=${sessionTag} port=${server.port} shared=${shared ? "yes" : "no"}${ + shared && shareCode ? ` code=${shareCode}` : "" + }`, ); break; case "detach": diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 240263a..e6c2de5 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -134,12 +134,14 @@ program .option("-p, --port ", "explicit port (skips registry lookup)") .option("-H, --host ", "host running the session", "127.0.0.1") .option("-r, --relay", "attach via relay using backend-issued ticket") + .option("-c, --code ", "share code from the host (required to join someone else's session)") .action(async (raw) => { await runAttach({ id: raw.id, port: raw.port ? Number(raw.port) : undefined, host: raw.host, relay: Boolean(raw.relay), + code: raw.code, }); }); diff --git a/packages/backend/convex/lib/relayTicket.ts b/packages/backend/convex/lib/relayTicket.ts index 8000b85..3dc9532 100644 --- a/packages/backend/convex/lib/relayTicket.ts +++ b/packages/backend/convex/lib/relayTicket.ts @@ -40,3 +40,16 @@ export async function hashRelayTicket(token: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); return toHex(new Uint8Array(digest)); } + +/** + * Normalize a share code so display formatting never affects matching: + * uppercase, and strip anything that is not A-Z or 0-9 (dashes, spaces). + * Both storing (owner) and verifying (viewer) must run this first. + */ +export function normalizeShareCode(code: string): string { + return code.toUpperCase().replace(/[^A-Z0-9]/g, ""); +} + +export async function hashShareCode(code: string): Promise { + return hashRelayTicket(normalizeShareCode(code)); +} diff --git a/packages/backend/convex/relay.ts b/packages/backend/convex/relay.ts index de2ef54..04f2532 100644 --- a/packages/backend/convex/relay.ts +++ b/packages/backend/convex/relay.ts @@ -5,7 +5,13 @@ import { internalMutation } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; import { createError, ErrorCode } from "./lib/errors.ts"; import { protectedAction, protectedMutation, publicMutation } from "./lib/middleware.ts"; -import { getRelayTicketConfig, createRelayTicket, hashRelayTicket } from "./lib/relayTicket.ts"; +import { + getRelayTicketConfig, + createRelayTicket, + hashRelayTicket, + hashShareCode, +} from "./lib/relayTicket.ts"; +import { enforceRateLimit } from "./lib/rateLimit.ts"; import { ErrorSeverity } from "./lib/types.ts"; const RELAY_TICKET = getRelayTicketConfig(); @@ -81,6 +87,9 @@ export const issueHostTicketInternal = internalMutation({ export const issueViewerTicket = protectedMutation({ args: { sessionId: v.string(), + // Access code shown by the host when it shared. Not required for the owner + // (an owner can attach to their own session from any of their devices). + code: v.optional(v.string()), }, handler: async (ctx, args) => { const session = await findActiveSession(ctx, args.sessionId); @@ -93,12 +102,38 @@ export const issueViewerTicket = protectedMutation({ } const isOwner = session.ownerUserId === ctx.userId; - if (!isOwner && !session.shared) { - throw createError({ - code: ErrorCode.INSUFFICIENT_PERMISSION, - message: "Session is not shared", - severity: ErrorSeverity.High, + if (!isOwner) { + // Throttle non-owner attempts per user+session so a leaked session id can + // never be paired with a brute-forced share code. + await enforceRateLimit(ctx, `issueViewerTicket:${ctx.userId}:${args.sessionId}`, { + limit: 10, + windowMs: 60_000, }); + + if (!session.shared || !session.shareCodeHash) { + throw createError({ + code: ErrorCode.INSUFFICIENT_PERMISSION, + message: "Session is not shared", + severity: ErrorSeverity.High, + }); + } + + const provided = args.code?.trim(); + if (!provided) { + throw createError({ + code: ErrorCode.INSUFFICIENT_PERMISSION, + message: "This session requires a share code", + severity: ErrorSeverity.High, + }); + } + const providedHash = await hashShareCode(provided); + if (providedHash !== session.shareCodeHash) { + throw createError({ + code: ErrorCode.INSUFFICIENT_PERMISSION, + message: "Invalid share code", + severity: ErrorSeverity.High, + }); + } } return await issueTicket(ctx, { diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 8bedcb5..196d286 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -25,6 +25,9 @@ export default defineSchema({ port: v.optional(v.number()), hostPid: v.optional(v.number()), shared: v.boolean(), + // SHA-256 of the normalized share code. Set when the owner shares; a viewer + // that is not the owner must present the matching code to get a viewer ticket. + shareCodeHash: v.optional(v.string()), relayState: v.union( v.literal("offline"), v.literal("connecting"), diff --git a/packages/backend/convex/session.ts b/packages/backend/convex/session.ts index f5a4758..33c1b53 100644 --- a/packages/backend/convex/session.ts +++ b/packages/backend/convex/session.ts @@ -3,6 +3,7 @@ import { internal } from "./_generated/api"; import { internalMutation } from "./_generated/server"; import { protectedMutation, protectedQuery } from "./lib/middleware.ts"; import { getSessionTimeoutConfig, shouldMarkSessionStale } from "./lib/sessionConfig.ts"; +import { hashShareCode } from "./lib/relayTicket.ts"; import { createError, ErrorCode } from "./lib/errors.ts"; import { ErrorSeverity } from "./lib/types.ts"; @@ -227,6 +228,60 @@ export const authorizeAttach = protectedQuery({ }, }); +/** + * Owner-only: start or stop sharing a session and set its access code. + * + * Passing a non-empty `code` marks the session shared and stores the SHA-256 of + * the normalized code. A non-owner viewer must later present the matching code + * to obtain a viewer ticket. Passing no code (or an empty string) stops sharing + * and clears the stored hash, so any outstanding access is revoked immediately. + */ +export const setShareCode = protectedMutation({ + args: { + sessionId: v.string(), + code: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("hostSession") + .withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId)) + .first(); + + if (!session) { + throw createError({ + code: ErrorCode.RESOURCE_NOT_FOUND, + message: "Session not found", + severity: ErrorSeverity.Medium, + }); + } + if (session.ownerUserId !== ctx.userId) { + throw createError({ + code: ErrorCode.INSUFFICIENT_PERMISSION, + message: "You cannot change sharing for another user's session", + severity: ErrorSeverity.High, + }); + } + + const code = args.code?.trim(); + const now = Date.now(); + if (code) { + await ctx.db.patch(session._id, { + shared: true, + shareCodeHash: await hashShareCode(code), + updatedAt: now, + }); + return { ok: true, shared: true }; + } + + await ctx.db.patch(session._id, { + shared: false, + shareCodeHash: undefined, + updatedAt: now, + }); + return { ok: true, shared: false }; + }, +}); + export const setRelayState = protectedMutation({ args: { sessionId: v.string(), diff --git a/packages/backend/tests/relay-ticket.test.ts b/packages/backend/tests/relay-ticket.test.ts index a2ccbdc..c078149 100644 --- a/packages/backend/tests/relay-ticket.test.ts +++ b/packages/backend/tests/relay-ticket.test.ts @@ -3,6 +3,8 @@ import { createRelayTicket, getRelayTicketConfig, hashRelayTicket, + hashShareCode, + normalizeShareCode, } from "../convex/lib/relayTicket"; describe("relay ticket config", () => { @@ -44,3 +46,23 @@ describe("relay ticket primitives", () => { expect(first).toMatch(/^[0-9a-f]{64}$/); }); }); + +describe("share code", () => { + test("normalizeShareCode uppercases and strips separators", () => { + expect(normalizeShareCode("abcd-efgh")).toBe("ABCDEFGH"); + expect(normalizeShareCode("ab cd-EF gh")).toBe("ABCDEFGH"); + }); + + test("hashShareCode ignores dash and case", async () => { + const a = await hashShareCode("ABCD-EFGH"); + const b = await hashShareCode("abcdefgh"); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + }); + + test("hashShareCode differs for different codes", async () => { + const a = await hashShareCode("ABCD-EFGH"); + const b = await hashShareCode("ABCD-EFGJ"); + expect(a).not.toBe(b); + }); +}); From 8b543b2b186ad4237767b16362f244a255bb84a9 Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Fri, 24 Jul 2026 15:26:22 +0300 Subject: [PATCH 2/7] feat(cli): enable WebRTC P2P by default with an opt-out P2P now defaults on so relay sessions get a direct low-latency data channel automatically, with the relay as the fallback. Set WRAPPER_P2P=0 (or false/off) to force the relay path. Documented the peer-IP exposure tradeoff in the env example. --- apps/cli/.env.example | 11 ++++++----- apps/cli/util/env.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/cli/.env.example b/apps/cli/.env.example index 89797b1..39940d5 100644 --- a/apps/cli/.env.example +++ b/apps/cli/.env.example @@ -12,11 +12,12 @@ WRAPPER_RELAY_URL="ws://localhost:8080" WRAPPER_AUTH_ORIGIN="http://localhost:3000" WRAPPER_HUD="on" -# Opt-in WebRTC P2P fast path for relay sessions. Off by default (relay is used). -# When "1"/"true"/"on", host and viewer try a direct data channel (lower latency) -# and fall back to the relay if it can't be established. Requires testing across -# real networks. STUN is public; P2P exposes peer IPs to each other (WebRTC norm). -WRAPPER_P2P="false" +# WebRTC P2P fast path for relay sessions. ON by default: host and viewer try a +# direct data channel (lower latency) and fall back to the relay automatically if +# it can't be established. Set to "0"/"false"/"off" to force the relay path. +# Note: a direct P2P connection exposes each peer's IP to the other, and STUN +# discloses your public IP to the STUN server (normal WebRTC behavior). +WRAPPER_P2P="1" # Optional logging and telemetry WRAPPER_LOG="info" diff --git a/apps/cli/util/env.ts b/apps/cli/util/env.ts index 108a407..1d7c15a 100644 --- a/apps/cli/util/env.ts +++ b/apps/cli/util/env.ts @@ -29,7 +29,9 @@ const IS_CI = process.env.CI !== undefined && process.env.CI !== ""; const HUD = (process.env.WRAPPER_HUD ?? "").toLowerCase(); const HUD_ENABLED = HUD !== "0" && HUD !== "false" && HUD !== "off"; const P2P = (process.env.WRAPPER_P2P ?? "").toLowerCase(); -const P2P_ENABLED = P2P === "1" || P2P === "true" || P2P === "on"; +// P2P is on by default (with automatic relay fallback). Opt out with +// WRAPPER_P2P=0 (or false/off) to force everything over the relay WebSocket. +const P2P_ENABLED = P2P !== "0" && P2P !== "false" && P2P !== "off"; export const env = { /** Development mode toggle. */ @@ -54,9 +56,9 @@ export const env = { /** CLI HUD toggle: on by default, set WRAPPER_HUD=off to disable. */ hudEnabled: HUD_ENABLED, /** - * Opt-in WebRTC P2P fast path (WRAPPER_P2P=1). Off by default: the relay - * WebSocket remains the transport. When on, host/viewer try a direct data - * channel and fall back to the relay if it can't be established. + * WebRTC P2P fast path. On by default: host/viewer try a direct data channel + * for low latency and fall back to the relay WebSocket if it cannot be + * established. Opt out with WRAPPER_P2P=0 (or false/off). */ p2pEnabled: P2P_ENABLED, } as const; From afb203c0508e9f66c4273bd801de4028046c320d Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Fri, 24 Jul 2026 15:26:22 +0300 Subject: [PATCH 3/7] docs: document share-code access model, shared-session behaviors, and P2P default Explain the capability-based access model (owner plus share code), the shared behaviors (shared control, multi-viewer, consensus resize, single host), the --code join flow, and the on-by-default P2P transport across the READMEs and the docs site. No em dashes. --- README.md | 13 +++--- apps/cli/README.md | 80 ++++++++++++++++++++++++------------ apps/cli/transport/README.md | 23 ++++++----- apps/docs/architecture.mdx | 23 +++++++++-- apps/docs/installation.mdx | 10 ++++- apps/docs/onboarding.mdx | 15 +++++-- apps/docs/setup.mdx | 4 +- apps/docs/transports.mdx | 20 ++++----- packages/backend/README.md | 31 +++++++++++++- 9 files changed, 156 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 448aa16..977fcfc 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,14 @@ terminal. 2. From the same machine, `wrapper attach` connects to that local server and mirrors the session. This path needs no account and no network. 3. When you press `Ctrl+\ s`, the CLI asks the Convex backend for a short-lived - host ticket, connects to the relay on Fly.io, and marks the session shared. - Another device runs `wrapper attach --relay` to join over the relay. -4. If `WRAPPER_P2P=1` is set on both ends, the relay is used only to exchange - WebRTC signaling, and the actual keystrokes and output move over a direct - peer-to-peer data channel for lower latency. The relay stays as the fallback. + host ticket, connects to the relay on Fly.io, marks the session shared, and + prints a secret share code. You join your own devices with + `wrapper attach --relay --id `; anyone else must pass the code with + `--code `, so knowing the session id alone is not enough. +4. By default the relay is used only to exchange WebRTC signaling, and the actual + keystrokes and output move over a direct peer-to-peer data channel for lower + latency. The relay stays as the fallback, and you can force it with + `WRAPPER_P2P=0`. ## Repository layout diff --git a/apps/cli/README.md b/apps/cli/README.md index 92a6388..f7c2a7e 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -97,7 +97,8 @@ Important safety guards: 1. Resolve target session by `--id`, `--port`, or picker from registry. 2. Local path: run `session:authorizeAttach`, then connect to `ws://127.0.0.1:`. 3. Relay path (`--relay` or no local match for `--id`): issue `relay:issueViewerTicket` - and connect to `WRAPPER_RELAY_URL/ws?ticket=...`. + (passing `--code` when joining someone else's session) and connect to + `WRAPPER_RELAY_URL/ws?ticket=...`. 4. Bridge stdin/stdout via protocol messages. 5. Support detach without ending host session (`Ctrl+\` then `d`). @@ -131,14 +132,39 @@ be either: - **Relay WebSocket** (`WebSocketTransport`), always used, and it also carries WebRTC signaling and is the fallback. -- **WebRTC data channel** (`WebRtcDataChannelTransport`, opt-in via - `WRAPPER_P2P`), a direct P2P connection negotiated over the relay. When it - opens, session traffic flows directly (SSH-like latency); if ICE can't connect, - the session transparently stays on the relay. +- **WebRTC data channel** (`WebRtcDataChannelTransport`), a direct P2P connection + negotiated over the relay. It is **on by default** (opt out with + `WRAPPER_P2P=0`). When it opens, session traffic flows directly (SSH-like + latency); if ICE can't connect, the session transparently stays on the relay. Full design, security model, and testing steps live in [`transport/README.md`](./transport/README.md). P2P applies only to `--relay` -attaches; local `127.0.0.1` attaches are already direct. +attaches; local `127.0.0.1` attaches are already direct. A direct connection +exposes each peer's IP to the other, so opt out if that matters for a session. + +## Sharing and access + +Sharing is capability-based, so only people you explicitly invite can join: + +- Pressing `Ctrl+\` `s` marks the session shared and prints a **share code** plus + the exact join command. The backend stores only the hash of that code. +- The owner can attach to their own session from any device with no code. +- Anyone else must pass the code: + `wrapper attach --relay --id --code `. Knowing the session id + alone is not enough, and guessing is rate limited. +- Pressing `Ctrl+\` `u` (or ending the shell) revokes access immediately by + clearing the stored code and shared flag. + +Behaviors to know once viewers join: + +- **Shared control:** every connected viewer can type into the same shell. This + is intended for pair-prompting. There is no watch-only viewer mode yet. +- **Multiple viewers:** many viewers can attach to one session at once; host + output is broadcast to all of them (and fanned out over each P2P channel). +- **Consensus resize:** the terminal is sized to the smallest connected viewer, + so a small window shrinks everyone's view. +- **One host per session:** a second host connecting to the same session id + replaces the first. ## Commands @@ -171,6 +197,7 @@ wrapper attach wrapper attach --id wrapper attach --port wrapper attach --relay --id +wrapper attach --relay --id --code wrapper logs --follow ``` @@ -192,8 +219,8 @@ Inside host shell: | Keys | Action | | ----------------- | ------------------------- | -| `Ctrl+\` `s` | mark shared | -| `Ctrl+\` `u` | mark unshared | +| `Ctrl+\` `s` | share + print share code | +| `Ctrl+\` `u` | unshare + revoke access | | `Ctrl+\` `?` | status overlay | | `Ctrl+\` `Ctrl+\` | send literal control byte | | `Ctrl+\` `Esc` | cancel prefix mode | @@ -205,8 +232,9 @@ Inside attach viewer: | `Ctrl+\` `d` | detach viewer | | `Ctrl+\` `?` | viewer status | -When shared and authenticated, `Ctrl+\` + `s` starts a relay bridge and enables -remote attach with `wrapper attach --relay --id `. +When shared and authenticated, `Ctrl+\` + `s` starts a relay bridge, prints a +share code, and enables remote attach with +`wrapper attach --relay --id --code `. ## Debugging workflow @@ -231,22 +259,22 @@ remote attach with `wrapper attach --relay --id `. ## Environment variables -| Variable | Description | Default | -| ----------------------- | ----------------------------------------- | -------------------------------------------- | -| `NODE_ENV` | runtime mode (`production` or dev) | unset (treated as dev) | -| `CI` | enable CI mode | unset | -| `WRAPPER_LOG` | `debug/info/warn/error/off` | dev=`info`, prod=`warn` | -| `WRAPPER_LOG_FILE` | override log file path | platform default | -| `WRAPPER_TELEMETRY` | `"false"` disables telemetry | enabled after consent | -| `WRAPPER_POSTHOG_KEY` | PostHog key | empty | -| `WRAPPER_TELEMETRY_URL` | telemetry endpoint | `https://telemetry.wrapper.sh` | -| `WRAPPER_RELAY_URL` | relay endpoint override | dev localhost, prod `wss://relay.wrapper.sh` | -| `WRAPPER_AUTH_ORIGIN` | auth callback origin | dev localhost, prod `https://wrapper.sh` | -| `WRAPPER_HUD` | HUD (`on/off`) | `on` | -| `WRAPPER_P2P` | opt-in WebRTC P2P fast path (`1/true/on`) | off (relay used) | -| `WRAPPER_CONVEX_URL` | Convex deployment URL for backend | falls back to `CONVEX_URL` if set | -| `WRAPPER_DISABLE` | disable hook in one terminal | unset | -| `WRAPPER_WRAPPED` | set by `shell-host` in inner shell | unset | +| Variable | Description | Default | +| ----------------------- | -------------------------------------------- | -------------------------------------------- | +| `NODE_ENV` | runtime mode (`production` or dev) | unset (treated as dev) | +| `CI` | enable CI mode | unset | +| `WRAPPER_LOG` | `debug/info/warn/error/off` | dev=`info`, prod=`warn` | +| `WRAPPER_LOG_FILE` | override log file path | platform default | +| `WRAPPER_TELEMETRY` | `"false"` disables telemetry | enabled after consent | +| `WRAPPER_POSTHOG_KEY` | PostHog key | empty | +| `WRAPPER_TELEMETRY_URL` | telemetry endpoint | `https://telemetry.wrapper.sh` | +| `WRAPPER_RELAY_URL` | relay endpoint override | dev localhost, prod `wss://relay.wrapper.sh` | +| `WRAPPER_AUTH_ORIGIN` | auth callback origin | dev localhost, prod `https://wrapper.sh` | +| `WRAPPER_HUD` | HUD (`on/off`) | `on` | +| `WRAPPER_P2P` | WebRTC P2P fast path; `0/false/off` opts out | on (relay is the fallback) | +| `WRAPPER_CONVEX_URL` | Convex deployment URL for backend | falls back to `CONVEX_URL` if set | +| `WRAPPER_DISABLE` | disable hook in one terminal | unset | +| `WRAPPER_WRAPPED` | set by `shell-host` in inner shell | unset | `NODE_ENV=development` redirects state into `wrapper-dev`, uses localhost defaults, and mirrors logs to stderr for easier local debugging. diff --git a/apps/cli/transport/README.md b/apps/cli/transport/README.md index 31be35e..55b118a 100644 --- a/apps/cli/transport/README.md +++ b/apps/cli/transport/README.md @@ -7,10 +7,10 @@ are transport-agnostic: they speak `@repo/protocol` frames through a small Two transports exist: -| Transport | File | Used for | -| ---------------------------- | -------------- | ------------------------------------------------ | -| `WebSocketTransport` | `transport.ts` | Relay path (always). Signaling + fallback. | -| `WebRtcDataChannelTransport` | `webrtc.ts` | Direct P2P fast path (opt-in via `WRAPPER_P2P`). | +| Transport | File | Used for | +| ---------------------------- | -------------- | ------------------------------------------------------------------- | +| `WebSocketTransport` | `transport.ts` | Relay path (always). Signaling + fallback. | +| `WebRtcDataChannelTransport` | `webrtc.ts` | Direct P2P fast path (on by default; opt out with `WRAPPER_P2P=0`). | ## Why P2P @@ -96,18 +96,19 @@ keeps one peer connection per viewer. ## Enabling it -`WRAPPER_P2P` is **off by default**, so the relay WebSocket is the transport and -behaviour is unchanged. Turn it on per-session: +`WRAPPER_P2P` is **on by default**, so host and viewer negotiate a direct data +channel automatically and fall back to the relay if it cannot be formed. Opt out +per session with `WRAPPER_P2P=0`: ```bash -# host -WRAPPER_P2P=1 bun run index.ts shell-host # then Ctrl+\ s to share -# viewer (relay attach) -WRAPPER_P2P=1 bun run index.ts attach --relay -i +# force the relay path (no P2P) +WRAPPER_P2P=0 bun run index.ts shell-host # then Ctrl+\ s to share +WRAPPER_P2P=0 bun run index.ts attach --relay -i -c ``` Look for `p2p data channel up` in both logs. P2P applies only to **relay** -attaches; local `127.0.0.1` attaches are already direct. +attaches; local `127.0.0.1` attaches are already direct. A direct connection +exposes each peer's IP to the other, which is why the opt-out exists. ## Testing diff --git a/apps/docs/architecture.mdx b/apps/docs/architecture.mdx index 3e91dbf..7dfd5f2 100644 --- a/apps/docs/architecture.mdx +++ b/apps/docs/architecture.mdx @@ -75,12 +75,29 @@ Terminal traffic can move over two transports, both abstracted behind the `Transport` interface in `apps/cli/transport`: - **Relay WebSocket** (default): frames travel host to relay to viewer. -- **Direct WebRTC** (opt-in with `WRAPPER_P2P=1`): the relay carries only the - offer, answer, and ICE candidates, and the terminal bytes flow over a direct - peer-to-peer data channel. If negotiation fails, the relay path keeps working. +- **Direct WebRTC** (on by default, opt out with `WRAPPER_P2P=0`): the relay + carries only the offer, answer, and ICE candidates, and the terminal bytes flow + over a direct peer-to-peer data channel. If negotiation fails, the relay path + keeps working. See [Transports](/transports) for the negotiation flow and the security model. +## Session access + +Access is capability-based and enforced in the backend: + +- A session that is not shared is owner only. +- Sharing (`Ctrl+\` `s`) calls `session:setShareCode`, which marks the session + shared and stores only the hash of a secret code the host prints. A non-owner + must pass that code to `relay:issueViewerTicket` (`wrapper attach --relay --id + --code `). The owner joins their own session without a code. +- Non-owner ticket requests are rate limited per user and session, and + unsharing clears the code so outstanding tickets stop working. + +Once viewers join, control is shared (every viewer can type), the terminal is +sized to the smallest viewer, and there is one host per session. See the CLI +README "Sharing and access" section for the full behavior list. + ## Billing `convex/billing.ts` exposes `createProCheckout`, which returns a Stripe checkout diff --git a/apps/docs/installation.mdx b/apps/docs/installation.mdx index e8696e4..169df20 100644 --- a/apps/docs/installation.mdx +++ b/apps/docs/installation.mdx @@ -76,8 +76,16 @@ Login first: bun run apps/cli/index.ts auth login ``` -Then use relay attach: +Then use relay attach. To join your own session from another device no code is +needed: ```bash bun run apps/cli/index.ts attach --relay --id ``` + +To join someone else's shared session, pass the share code they printed when they +shared (`Ctrl+\` then `s`): + +```bash +bun run apps/cli/index.ts attach --relay --id --code +``` diff --git a/apps/docs/onboarding.mdx b/apps/docs/onboarding.mdx index b9ba189..e641f62 100644 --- a/apps/docs/onboarding.mdx +++ b/apps/docs/onboarding.mdx @@ -35,16 +35,25 @@ This is your owner terminal. In host terminal: -- `Ctrl+\` then `s` to share -- `Ctrl+\` then `u` to unshare +- `Ctrl+\` then `s` to share (prints a share code and the join command) +- `Ctrl+\` then `u` to unshare (revokes access immediately) - `Ctrl+\` then `?` for status -From another terminal: +From another device signed in as you, no code is needed: ```bash wrapper attach --relay --id ``` +To let someone else in (for example pair-prompting), give them the share code: + +```bash +wrapper attach --relay --id --code +``` + +Every viewer who joins can type into the same shell, so only share the code with +people you want to co-drive the session. + ## 5) Done Once these steps are complete, onboarding is marked as finished for your account. diff --git a/apps/docs/setup.mdx b/apps/docs/setup.mdx index 2f21472..fec65c6 100644 --- a/apps/docs/setup.mdx +++ b/apps/docs/setup.mdx @@ -11,8 +11,8 @@ description: Configure environment variables, run checks, and verify your local - `WRAPPER_RELAY_URL`: Relay WebSocket base URL - `WRAPPER_AUTH_ORIGIN`: Auth UI origin - `WRAPPER_HUD`: `on` by default, set `off` to disable host HUD/title updates -- `WRAPPER_P2P`: set to `1` to opt in to the direct WebRTC path (off by default, - relay stays the fallback). See [Transports](/transports). +- `WRAPPER_P2P`: WebRTC direct path, on by default with automatic relay fallback. + Set to `0` to force the relay. See [Transports](/transports). - `NODE_ENV=development`: local development mode ### Relay diff --git a/apps/docs/transports.mdx b/apps/docs/transports.mdx index ae87dad..bcef6b5 100644 --- a/apps/docs/transports.mdx +++ b/apps/docs/transports.mdx @@ -8,10 +8,10 @@ Wrapper protocol frames can travel over two transports. Both sit behind a small (`relay/host-bridge.ts`) and the viewer (`client/attach-client.ts`) never touch a socket directly. -| Transport | File | Used for | -| -------------------------- | -------------- | --------------------------------------------------- | -| Relay WebSocket | `transport.ts` | The relay path, always. Signaling and fallback. | -| Direct WebRTC data channel | `webrtc.ts` | The direct P2P fast path, opt-in via `WRAPPER_P2P`. | +| Transport | File | Used for | +| -------------------------- | -------------- | ---------------------------------------------------------------------- | +| Relay WebSocket | `transport.ts` | The relay path, always. Signaling and fallback. | +| Direct WebRTC data channel | `webrtc.ts` | The direct P2P fast path, on by default; opt out with `WRAPPER_P2P=0`. | ## Why a direct path @@ -67,14 +67,14 @@ fallback if ICE cannot connect in time. ## Enabling it -`WRAPPER_P2P` is off by default, so the relay WebSocket is the transport and -behavior is unchanged. Turn it on per session on both ends: +`WRAPPER_P2P` is on by default, so host and viewer negotiate a direct data +channel automatically and fall back to the relay if it cannot be formed. Opt out +per session on either end with `WRAPPER_P2P=0`: ```bash -# host -WRAPPER_P2P=1 wrapper shell-host # then Ctrl+\ s to share -# viewer -WRAPPER_P2P=1 wrapper attach --relay -i +# force the relay path (no P2P) +WRAPPER_P2P=0 wrapper shell-host # then Ctrl+\ s to share +WRAPPER_P2P=0 wrapper attach --relay -i -c ``` Look for `p2p data channel up` in both logs. P2P applies only to relay attaches; diff --git a/packages/backend/README.md b/packages/backend/README.md index ba74c6d..0b91ffd 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -39,6 +39,8 @@ who may attach to which session. - `listActive`: list active sessions for the authenticated owner - `authorizeAttach`: allow attach only if the caller is the owner or the session is shared +- `setShareCode`: owner-only; start sharing and store the SHA-256 of the access + code, or stop sharing and clear it (revoking outstanding access) - `markStaleIfTimedOut` (internal): scheduler task that auto-closes stale active sessions - `setRelayState`: owner-only relay presence sync (`offline`, `connecting`, @@ -59,8 +61,10 @@ who may attach to which session. - `issueHostTicket`: owner-only short-lived ticket for the host relay socket, gated by the Autumn sharing entitlement -- `issueViewerTicket`: short-lived ticket for a viewer socket (owner or a shared - session) +- `issueViewerTicket`: short-lived ticket for a viewer socket. The owner is + always allowed on their own devices. A non-owner must present the correct + share code, and non-owner attempts are rate limited per user and session to + stop code guessing - `consumeTicket`: single-use consumption during the relay handshake, called by the relay itself without a user identity - `cleanupTicket` (internal): scheduled cleanup of used and expired ticket rows @@ -98,6 +102,29 @@ who may attach to which session. - Session liveness uses a heartbeat timeout. Missing heartbeats trigger scheduler cleanup and close the session with `closeReason: "stale_timeout"`. +## Session access model + +Access to a session is deliberately narrow: + +- **Not shared:** owner only. Every lifecycle call (open, heartbeat, close, + relay state, host ticket) rejects anyone who is not the owner. +- **Shared:** the owner runs `setShareCode`, which marks the session shared and + stores only the SHA-256 of a secret code the host generates. To join, a + non-owner must call `issueViewerTicket` with the matching code. Knowing the + session id alone is not enough, and the owner can still join their own session + from any device without a code. +- **Revocation:** unsharing (or closing) clears the code hash, and + `consumeTicket` re-checks the shared state, so outstanding viewer tickets stop + working the moment the owner unshares. +- **Anti-guessing:** non-owner `issueViewerTicket` calls are rate limited per + user and session, so a leaked session id cannot be paired with a brute-forced + code. + +This is a capability model (possession of the code grants access), which suits +pair-prompting on a shared session. Note that every viewer who joins shares +control of the same shell (anyone connected can type). A per-join host approval +step could be layered on later if watch-only viewers are needed. + ## Relay ticket security - Tickets are random tokens. Only their hash is stored (`tokenHash`), so a From 1b73eb8cbb2d04d763a926e59f815bbb5dbf67e8 Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Fri, 24 Jul 2026 21:14:41 +0300 Subject: [PATCH 4/7] feat(cli): auto-refresh the Convex JWT for long-running hosts A shell-host minted one Convex JWT at startup and reused it forever, so sessions that outlived the token started failing every heartbeat, share, and close call with an expired-token error. The host now re-mints the JWT from the stored session token before it expires (and reactively on an auth-expiry error), and stops the refresh on shutdown. --- apps/cli/commands/shell-host.ts | 40 +++++++++- apps/cli/tests/convex-client.test.ts | 25 ++++++ apps/cli/util/convex-client.ts | 113 ++++++++++++++++++++++++++- packages/backend/README.md | 5 +- 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 apps/cli/tests/convex-client.test.ts diff --git a/apps/cli/commands/shell-host.ts b/apps/cli/commands/shell-host.ts index 67167e8..52a118b 100644 --- a/apps/cli/commands/shell-host.ts +++ b/apps/cli/commands/shell-host.ts @@ -9,7 +9,11 @@ import { startRelayHostBridge, type RelayHostBridge } from "../relay/host-bridge import { registerSession, setSessionShared, unregisterSession } from "../registry/sessions"; import { startLocalServer, type LocalServerHandle } from "../server/local"; import { PrefixFilter, type PrefixCommand } from "../shell/prefix"; -import { resolveAuthedConvexClient } from "../util/convex-client"; +import { + resolveAuthedConvexClient, + startAuthAutoRefresh, + type AuthAutoRefresh, +} from "../util/convex-client"; import { env } from "../util/env"; import { bell, clearTitle, inlineMessage, notifyOS, setTitle } from "../util/feedback"; import { installShutdownHandlers, type ShutdownReason } from "../util/signals"; @@ -97,6 +101,15 @@ const setShareCodeRef = makeFunctionReference< { ok: boolean; shared: boolean } >("session:setShareCode"); +/** 256-bit hex secret gating connections to the local WebSocket server. */ +function createLocalToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + let out = ""; + for (const byte of bytes) out += byte.toString(16).padStart(2, "0"); + return out; +} + // Crockford-style alphabet (no I, L, O, U) so codes are easy to read and type. const SHARE_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; @@ -130,6 +143,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { } const sessionId = createSessionId(); + const localToken = createLocalToken(); const initialSize = currentSize(); const session = new PtySession({ @@ -160,6 +174,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { port: opts.port ?? 0, sessionId, pty: session, + token: localToken, }); } catch (err) { log.error("failed to start local server", { @@ -189,6 +204,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { shell: resolvedShell, createdAt: new Date().toISOString(), shared: false, + localToken, }); const backend = await resolveAuthedConvexClient(); @@ -212,6 +228,20 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { log.warn("backend auth failed; backend sync disabled", { error: backend.error.message }); } + // Keep the short-lived Convex JWT fresh for the whole life of the host. Without + // this, a session that outlives the initial token starts failing every backend + // call (heartbeats, share, close) with an expired-token error. + let authRefresh: AuthAutoRefresh | null = null; + if (backend.status === "ready") { + authRefresh = startAuthAutoRefresh({ + client: backend.client, + convexUrl: backend.convexUrl, + sessionToken: backend.sessionToken, + jwt: backend.jwt, + log, + }); + } + let shared = false; let shareCode: string | null = null; let relayBridge: RelayHostBridge | null = null; @@ -242,6 +272,11 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { }) .catch((error: unknown) => { const err = error instanceof Error ? error : new Error(String(error)); + // An expired JWT surfaces here first; kick a refresh so the next tick + // (and any share/close call) succeeds instead of looping on the error. + if (/InvalidAuthHeader|expired|Unauthenticated/i.test(err.message)) { + void authRefresh?.refreshNow(); + } log.warn("session heartbeat failed", { error: err.message }); }); }, HEARTBEAT_INTERVAL_MS); @@ -442,7 +477,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { // Reuse attach-client path so host and viewer go through same protocol. - const url = `ws://127.0.0.1:${server.port}`; + const url = `ws://127.0.0.1:${server.port}?token=${localToken}`; const attach: AttachClientHandle = startAttachClient({ url, initialSize, @@ -457,6 +492,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { shuttingDown = true; log.debug("shell-host shutting down", { sessionId, reason }); clearInterval(heartbeat); + authRefresh?.stop(); await stopRelayBridge(); if (backend.status === "ready") { try { diff --git a/apps/cli/tests/convex-client.test.ts b/apps/cli/tests/convex-client.test.ts new file mode 100644 index 0000000..fed2cc5 --- /dev/null +++ b/apps/cli/tests/convex-client.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { decodeJwtExpMs } from "../util/convex-client"; + +function makeJwt(payload: Record): string { + const json = JSON.stringify(payload); + const b64 = btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `header.${b64}.signature`; +} + +describe("decodeJwtExpMs", () => { + test("reads the exp claim and converts seconds to ms", () => { + const jwt = makeJwt({ sub: "user_1", exp: 1_700_000_000 }); + expect(decodeJwtExpMs(jwt)).toBe(1_700_000_000_000); + }); + + test("returns null when exp is missing", () => { + const jwt = makeJwt({ sub: "user_1" }); + expect(decodeJwtExpMs(jwt)).toBeNull(); + }); + + test("returns null for a malformed token", () => { + expect(decodeJwtExpMs("not-a-jwt")).toBeNull(); + expect(decodeJwtExpMs("")).toBeNull(); + }); +}); diff --git a/apps/cli/util/convex-client.ts b/apps/cli/util/convex-client.ts index 6a4b77e..2a4121d 100644 --- a/apps/cli/util/convex-client.ts +++ b/apps/cli/util/convex-client.ts @@ -6,6 +6,10 @@ export type ConvexClientResolution = status: "ready"; client: ConvexHttpClient; convexUrl: string; + /** Better Auth session token used to mint the current JWT. */ + sessionToken: string; + /** The freshly-minted Convex JWT the client was authenticated with. */ + jwt: string; } | { status: "unconfigured"; @@ -39,7 +43,7 @@ export async function resolveAuthedConvexClient(): Promise) => void; + warn: (message: string, data?: Record) => void; +} + +export interface AuthAutoRefresh { + /** Cancel the scheduled refresh (call on shutdown). */ + stop: () => void; + /** Force an immediate refresh (used reactively on an auth-expiry error). */ + refreshNow: () => Promise; +} + +/** + * Keep a long-running client's Convex JWT valid. + * + * The device-auth session token is long-lived, but the Convex JWT minted from it + * is short-lived, so a host that runs for hours would otherwise start failing + * every backend call once the initial JWT expires. This re-exchanges the session + * token for a fresh JWT shortly before each one expires (and on demand), calling + * `setAuth` again. If the session token itself has expired, the exchange fails + * and the warnings tell the user to run `wrapper auth login` again. + */ +export function startAuthAutoRefresh(params: { + client: ConvexHttpClient; + convexUrl: string; + sessionToken: string; + jwt: string; + log?: AuthAutoRefreshLogger; +}): AuthAutoRefresh { + const { client, convexUrl, sessionToken, jwt, log } = params; + const MIN_DELAY_MS = 30_000; + const FALLBACK_DELAY_MS = 30 * 60_000; + const SKEW_MS = 60_000; + + let timer: ReturnType | null = null; + let stopped = false; + let refreshing: Promise | null = null; + + const arm = (delayMs: number): void => { + if (stopped) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + void refreshNow(); + }, delayMs); + timer.unref?.(); + }; + + const scheduleFromJwt = (currentJwt: string): void => { + const expMs = decodeJwtExpMs(currentJwt); + const delay = expMs ? Math.max(MIN_DELAY_MS, expMs - Date.now() - SKEW_MS) : FALLBACK_DELAY_MS; + arm(delay); + }; + + const refreshNow = (): Promise => { + if (stopped) return Promise.resolve(false); + if (refreshing) return refreshing; + refreshing = (async () => { + try { + const nextJwt = await exchangeSessionForJwt(convexUrl, sessionToken); + client.setAuth(nextJwt); + log?.debug("refreshed convex auth token"); + scheduleFromJwt(nextJwt); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn("failed to refresh convex auth token", { error: message }); + // Retry soon in case the failure was transient; a genuinely expired + // session keeps failing and the repeated warning prompts a re-login. + arm(MIN_DELAY_MS); + return false; + } finally { + refreshing = null; + } + })(); + return refreshing; + }; + + scheduleFromJwt(jwt); + + return { + stop: () => { + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + }, + refreshNow, + }; +} diff --git a/packages/backend/README.md b/packages/backend/README.md index 0b91ffd..93cd3c6 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -97,8 +97,9 @@ who may attach to which session. sessions stop validating) rather than starting with a predictable key. - The CLI authenticates with the device authorization flow, stores a session token, then exchanges that token for a short-lived Convex JWT through the - `bearer()` plugin before calling any Convex function. See - `apps/cli/util/convex-client.ts`. + `bearer()` plugin before calling any Convex function. A long-running host + re-mints the JWT from the stored session token before it expires, so hours-long + sessions keep authenticating. See `apps/cli/util/convex-client.ts`. - Session liveness uses a heartbeat timeout. Missing heartbeats trigger scheduler cleanup and close the session with `closeReason: "stale_timeout"`. From 836028b6e0e02569edee6a0fea710907f998e18b Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Fri, 24 Jul 2026 21:14:41 +0300 Subject: [PATCH 5/7] fix(cli): require a per-session token to attach to the local WebSocket The loopback WS server upgraded any connection with no auth, so on a shared machine another local user could attach to a shell by reaching 127.0.0.1. The host now generates a 256-bit token, stores it only in the 0600 registry, and requires it as ?token= on every local connection. attach reads it from the registry, and it is redacted from logs. The token is optional so headless tests are unaffected. --- apps/cli/README.md | 14 ++++++++++++++ apps/cli/commands/attach.ts | 21 +++++++++++++-------- apps/cli/registry/sessions.ts | 6 ++++++ apps/cli/server/local.ts | 16 ++++++++++++++++ apps/cli/transport/transport.ts | 2 +- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index f7c2a7e..355b0c1 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -166,6 +166,20 @@ Behaviors to know once viewers join: - **One host per session:** a second host connecting to the same session id replaces the first. +### Local attach security + +The local WebSocket server listens on `127.0.0.1`, which any process on the +machine can reach. To stop another local user from attaching to your shell, the +host generates a per-session loopback token, stores it only in the `0600` +registry inside your `0700` config dir, and requires it as `?token=` on every +local connection. `wrapper attach` reads the token from the registry, so only a +user who can read your registry (you, or root) can attach locally. The token is +redacted from logs. + +Long-running hosts also keep their backend auth fresh: the short-lived Convex JWT +is re-minted from the stored session token before it expires, so a session that +runs for hours does not start failing its heartbeats. + ## Commands ### Install and hook management diff --git a/apps/cli/commands/attach.ts b/apps/cli/commands/attach.ts index 279e09c..938114d 100644 --- a/apps/cli/commands/attach.ts +++ b/apps/cli/commands/attach.ts @@ -87,9 +87,10 @@ export async function runAttach(opts: AttachOptions): Promise { code: opts.code, }); if (!url) process.exit(1); - // Relay URLs carry a single-use join ticket in the query string. Redact it so - // the credential never lands in the log file or the terminal scrollback. - const safeUrl = url.replace(/ticket=[^&]+/, "ticket=***"); + // Relay URLs carry a single-use join ticket, and local URLs carry the loopback + // token, in the query string. Redact both so no credential lands in the log + // file or the terminal scrollback. + const safeUrl = url.replace(/ticket=[^&]+/, "ticket=***").replace(/token=[^&]+/, "token=***"); log.info("attaching", { url: safeUrl, sessionId: target.id }); trackEvent("attach_started"); process.stderr.write(`[wrapper] attaching to ${safeUrl}\n`); @@ -202,7 +203,7 @@ export async function runAttach(opts: AttachOptions): Promise { async function resolveTarget(opts: AttachOptions): Promise { if (opts.id) { const found = findSession(opts.id); - if (found) return { id: found.id, port: found.port, local: true }; + if (found) return { id: found.id, port: found.port, local: true, localToken: found.localToken }; return { id: opts.id, local: false }; } @@ -211,7 +212,9 @@ async function resolveTarget(opts: AttachOptions): Promise // authorized. If the port isn't a known local session, keep it unknown — // authorization will then refuse (when a backend is configured). const byPort = findSessionByPort(opts.port); - if (byPort) return { id: byPort.id, port: byPort.port, local: true }; + if (byPort) { + return { id: byPort.id, port: byPort.port, local: true, localToken: byPort.localToken }; + } return { id: "", port: opts.port, local: true }; } @@ -224,7 +227,7 @@ async function resolveTarget(opts: AttachOptions): Promise } if (sessions.length === 1) { const only = sessions[0]!; - return { id: only.id, port: only.port, local: true }; + return { id: only.id, port: only.port, local: true, localToken: only.localToken }; } const picked = await pickSession(sessions); @@ -235,6 +238,7 @@ interface TargetSession { id: string; port?: number; local: boolean; + localToken?: string; } async function pickSession(sessions: SessionRecord[]): Promise { @@ -251,7 +255,7 @@ async function pickSession(sessions: SessionRecord[]): Promise s.id === choice); if (!found) return null; - return { id: found.id, port: found.port, local: true }; + return { id: found.id, port: found.port, local: true, localToken: found.localToken }; } function shortShell(path: string): string { @@ -308,7 +312,8 @@ async function resolveAttachUrl(input: { if (!input.preferRelay && input.target.local && input.target.port !== undefined) { const allowed = await ensureAttachAllowed(input.target); if (!allowed) return null; - return `ws://${input.host}:${input.target.port}`; + const base = `ws://${input.host}:${input.target.port}`; + return input.target.localToken ? `${base}?token=${input.target.localToken}` : base; } if (input.target.id === "") { diff --git a/apps/cli/registry/sessions.ts b/apps/cli/registry/sessions.ts index 7b612c7..b724a98 100644 --- a/apps/cli/registry/sessions.ts +++ b/apps/cli/registry/sessions.ts @@ -54,6 +54,12 @@ export interface SessionRecord { createdAt: string; /** Whether the session has opted in to relay forwarding. */ shared: boolean; + /** + * Secret required to connect to the local WebSocket server. Stored only in the + * 0600 registry inside the owner's 0700 config dir, so only the owner (or root) + * can read it. Prevents another local user from attaching to the loopback port. + */ + localToken?: string; } interface RegistryFile { diff --git a/apps/cli/server/local.ts b/apps/cli/server/local.ts index 3ae0b47..117914a 100644 --- a/apps/cli/server/local.ts +++ b/apps/cli/server/local.ts @@ -19,6 +19,12 @@ export interface LocalServerOptions { hostname?: string; sessionId: SessionId; pty: PtySession; + /** + * Secret that a client must present as `?token=` to connect. Loopback ports are + * reachable by any local process, so without this any user on the machine could + * attach to the shell. When omitted, no token is required (kept for tests). + */ + token?: string; } export interface LocalServerHandle { @@ -38,6 +44,16 @@ export function startLocalServer(opts: LocalServerOptions): LocalServerHandle { port: opts.port, hostname: opts.hostname ?? "127.0.0.1", fetch(req, srv) { + // Loopback ports are reachable by any local process, so gate the upgrade + // on the per-session token before touching the PTY. Constant-time compare + // is unnecessary here (attacker cannot observe timing across a rejected + // WS upgrade), but the token itself is 256-bit and unguessable. + if (opts.token) { + const presented = new URL(req.url).searchParams.get("token"); + if (presented !== opts.token) { + return new Response("Unauthorized", { status: 401 }); + } + } const ok = srv.upgrade(req, { data: { sessionId: opts.sessionId, size: null } }); if (ok) return undefined; return new Response("Expected WebSocket upgrade", { status: 426 }); diff --git a/apps/cli/transport/transport.ts b/apps/cli/transport/transport.ts index ee46e4f..a812d4f 100644 --- a/apps/cli/transport/transport.ts +++ b/apps/cli/transport/transport.ts @@ -34,7 +34,7 @@ export class WebSocketTransport implements Transport { readonly describe: string; constructor(url: string, handlers: TransportHandlers) { - this.describe = url.replace(/ticket=[^&]+/, "ticket=***"); + this.describe = url.replace(/ticket=[^&]+/, "ticket=***").replace(/token=[^&]+/, "token=***"); this.ws = new WebSocket(url); this.ws.binaryType = "arraybuffer"; From 9b7b01a1b4fbef83be3357b7c01a48884ea5a389 Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Mon, 27 Jul 2026 14:25:40 +0300 Subject: [PATCH 6/7] feat(cli): add a persistent session HUD and resilient P2P fallback Keep role, session, and active transport visible in the terminal title without reserving a row that would break full-screen apps. Reveal context controls when the prefix is armed, repaint after shell title changes, and make P2P failures switch back to the relay promptly while preserving a live direct channel if the signaling socket closes. --- apps/cli/client/attach-client.ts | 72 ++++++++++++++++++- apps/cli/commands/attach.ts | 53 +++++++++++--- apps/cli/commands/shell-host.ts | 79 ++++++++++++++++++-- apps/cli/relay/host-bridge.ts | 29 +++++++- apps/cli/tests/feedback.test.ts | 43 +++++++++++ apps/cli/transport/transport.ts | 4 +- apps/cli/transport/webrtc.ts | 120 +++++++++++++++++++++++++------ apps/cli/util/feedback.ts | 36 ++++++++++ 8 files changed, 393 insertions(+), 43 deletions(-) create mode 100644 apps/cli/tests/feedback.test.ts diff --git a/apps/cli/client/attach-client.ts b/apps/cli/client/attach-client.ts index 8e01d6e..8ef5130 100644 --- a/apps/cli/client/attach-client.ts +++ b/apps/cli/client/attach-client.ts @@ -18,10 +18,16 @@ export interface AttachClientOptions { connectRetries?: number; connectRetryDelayMs?: number; interceptStdin?: (chunk: string) => string | null; - /** Opt-in viewer P2P: negotiate a direct data channel for this session. */ + /** Viewer P2P: negotiate a direct data channel for this relay session. */ p2p?: { sessionId: SessionId }; + /** Reports the currently active data path for HUD/status feedback. */ + onTransportChange?: (status: AttachTransportStatus) => void; + /** Called after remote output writes an OSC window-title sequence. */ + onTerminalTitle?: () => void; } +export type AttachTransportStatus = "connecting" | "local" | "relay" | "p2p" | "closed"; + export interface AttachResult { sessionId: SessionId | null; exitCode: number | null; @@ -50,6 +56,7 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle let exitCode: number | null = null; let reason: AttachResult["reason"] = "socket_closed"; let lastError: Error | undefined; + let titleSequenceTail = ""; let resolveDone: (result: AttachResult) => void; const done = new Promise((resolve) => { @@ -60,10 +67,18 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle let rawModeEnabled = false; let ioAttached = false; let finalized = false; - // P2P fast path (opt-in). The relay WS stays as signaling + fallback; once the + // P2P fast path. The relay WS stays as signaling + fallback; once the // data channel is open it carries session traffic and relay output is deduped. let dataTransport: Transport | null = null; let negotiation: Negotiation | null = null; + let transportStatus: AttachTransportStatus = "connecting"; + + const reportTransport = (status: AttachTransportStatus): void => { + if (transportStatus === status) return; + transportStatus = status; + opts.onTransportChange?.(status); + }; + opts.onTransportChange?.(transportStatus); // `stdin.isTTY` can be unreliable under bun run, so check setRawMode directly. const canRawMode = typeof stdin.setRawMode === "function"; @@ -134,6 +149,7 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle return { sessionId, exitCode, reason, error: lastError }; } finalized = true; + reportTransport("closed"); detachIO(); negotiation?.cancel(); dataTransport?.close(); @@ -147,6 +163,7 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle return makeTransport({ onOpen: () => { log.debug("transport connected", { attempt: attempts }); + reportTransport(opts.p2p ? "relay" : "local"); attachIO(); if (opts.p2p && !negotiation) startP2P(opts.p2p.sessionId); }, @@ -175,6 +192,14 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle }, retryDelayMs); return; } + // A live P2P channel can keep the session running even if its signaling + // WebSocket disappears. If the data channel later drops too, its close + // handler finalizes because no relay fallback remains. + if (dataTransport?.isOpen) { + log.warn("relay transport closed; continuing over p2p"); + reportTransport("p2p"); + return; + } log.debug("transport closed"); finalize(); }, @@ -186,6 +211,10 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle }); return; } + if (dataTransport?.isOpen) { + log.warn("relay transport error; continuing over p2p", { error: info.message }); + return; + } lastError = new Error(`transport error: ${info.message ?? "unknown"}`); reason = "error"; log.error("transport error", { error: lastError.message }); @@ -207,6 +236,22 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle break; case "output": stdout.write(msg.data); + // Shell prompts and TUIs often set OSC 0/1/2 titles. Repaint Wrapper's + // session HUD after those sequences so it stays persistent without + // filtering or altering the actual PTY output. Keep a short tail so a + // sequence split across protocol frames is still detected. + { + const titleScan = titleSequenceTail + msg.data; + titleSequenceTail = titleScan.slice(-8); + const osc = "\u001B]"; + if ( + titleScan.includes(`${osc}0;`) || + titleScan.includes(`${osc}1;`) || + titleScan.includes(`${osc}2;`) + ) { + opts.onTerminalTitle?.(); + } + } break; case "session.closed": exitCode = msg.exitCode; @@ -242,8 +287,26 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle }, onClose: () => { dataTransport = null; + if (transport.isOpen) { + log.info("p2p data channel down; using relay fallback"); + reportTransport("relay"); + } else if (!finalized) { + finalize(); + } + }, + onError: (info) => { + dataTransport = null; + if (transport.isOpen) { + log.warn("p2p data channel failed; using relay fallback", { + error: info.message, + }); + reportTransport("relay"); + } else if (!finalized) { + lastError = new Error(`p2p transport error: ${info.message ?? "unknown"}`); + reason = "error"; + finalize(); + } }, - onError: () => {}, }, // Signaling always travels over the relay WS (never the data channel). sendSignal: ({ kind, data }) => { @@ -264,7 +327,10 @@ export function startAttachClient(opts: AttachClientOptions): AttachClientHandle const t = await negotiation?.transport; if (t) { dataTransport = t; + reportTransport("p2p"); log.info("p2p data channel up (viewer)"); + } else if (transport.isOpen) { + reportTransport("relay"); } })(); } diff --git a/apps/cli/commands/attach.ts b/apps/cli/commands/attach.ts index 938114d..cd3238d 100644 --- a/apps/cli/commands/attach.ts +++ b/apps/cli/commands/attach.ts @@ -1,7 +1,7 @@ import * as p from "@clack/prompts"; import { createLogger, trackError, trackEvent } from "@repo/logger"; import { makeFunctionReference } from "convex/server"; -import { startAttachClient } from "../client/attach-client"; +import { startAttachClient, type AttachTransportStatus } from "../client/attach-client"; import { findSession, findSessionByPort, @@ -12,7 +12,15 @@ import { import { PrefixFilter, type PrefixCommand } from "../shell/prefix"; import { resolveAuthedConvexClient } from "../util/convex-client"; import { env } from "../util/env"; -import { bell, clearTitle, inlineMessage, setTitle } from "../util/feedback"; +import { + bell, + clearTitle, + formatControlsHint, + formatSessionHud, + inlineMessage, + setTitle, + type SessionTransportStatus, +} from "../util/feedback"; import { installShutdownHandlers } from "../util/signals"; const log = createLogger("attach"); @@ -94,10 +102,29 @@ export async function runAttach(opts: AttachOptions): Promise { log.info("attaching", { url: safeUrl, sessionId: target.id }); trackEvent("attach_started"); process.stderr.write(`[wrapper] attaching to ${safeUrl}\n`); - process.stderr.write(`[wrapper] press Ctrl+\\ then 'd' to detach (session keeps running)\n`); + process.stderr.write(`[wrapper] ${formatControlsHint("viewer")} (session keeps running)\n`); let userAborted = false; const sessionTag = target.id.slice(0, 6); + const usingRelay = url.includes("/ws?ticket="); + let transportStatus: AttachTransportStatus = "connecting"; + + const hudTransport = (): SessionTransportStatus => { + if (transportStatus === "closed") return "offline"; + return transportStatus; + }; + + const paintViewerTitle = (armed = false): void => { + if (!env.hudEnabled) return; + setTitle( + formatSessionHud({ + role: "viewer", + sessionTag, + transport: hudTransport(), + armed, + }), + ); + }; /* * Wrapper's keystroke prefix on the attach side. The host has its @@ -122,8 +149,12 @@ export async function runAttach(opts: AttachOptions): Promise { void handle.detach(); break; case "status": - inlineMessage(`viewing ${sessionTag} on port ${target.port}`); - setTitle(`wrapper • viewer • ${sessionTag}`); + inlineMessage( + `viewing ${sessionTag} transport=${hudTransport()}${ + target.port === undefined ? "" : ` port=${target.port}` + }`, + ); + paintViewerTitle(); break; case "share": case "unshare": @@ -140,17 +171,16 @@ export async function runAttach(opts: AttachOptions): Promise { onForward: (data) => handle.forwardInput(data), onArmedChange: (armed) => { if (armed) { - setTitle(`● wrapper armed • viewer • ${sessionTag}`); + paintViewerTitle(true); bell(); } else { - setTitle(`wrapper • viewer • ${sessionTag}`); + paintViewerTitle(); } }, }); // P2P applies only to relay attaches (remote peers); local 127.0.0.1 attaches // are already direct. Relay URLs carry the `/ws?ticket=` path. - const usingRelay = url.includes("/ws?ticket="); const handle = startAttachClient({ url, initialSize: { @@ -161,10 +191,15 @@ export async function runAttach(opts: AttachOptions): Promise { connectRetryDelayMs: 100, interceptStdin: (chunk) => prefixFilter.process(chunk), p2p: env.p2pEnabled && usingRelay ? { sessionId: target.id } : undefined, + onTransportChange: (status) => { + transportStatus = status; + paintViewerTitle(); + }, + onTerminalTitle: paintViewerTitle, }); // Initial title so the user sees this is a viewer window. - setTitle(`wrapper • viewer • ${sessionTag}`); + paintViewerTitle(); const signals = installShutdownHandlers({ onShutdown: async () => { diff --git a/apps/cli/commands/shell-host.ts b/apps/cli/commands/shell-host.ts index 52a118b..7313628 100644 --- a/apps/cli/commands/shell-host.ts +++ b/apps/cli/commands/shell-host.ts @@ -15,7 +15,16 @@ import { type AuthAutoRefresh, } from "../util/convex-client"; import { env } from "../util/env"; -import { bell, clearTitle, inlineMessage, notifyOS, setTitle } from "../util/feedback"; +import { + bell, + clearTitle, + formatControlsHint, + formatSessionHud, + inlineMessage, + notifyOS, + setTitle, + type SessionTransportStatus, +} from "../util/feedback"; import { installShutdownHandlers, type ShutdownReason } from "../util/signals"; const log = createLogger("shell-host"); @@ -245,6 +254,8 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { let shared = false; let shareCode: string | null = null; let relayBridge: RelayHostBridge | null = null; + let relayConnected = false; + let p2pPeerCount = 0; // Guards against a second `share` press racing the in-flight relay setup // (we no longer optimistically flip `shared` to serve as that guard). let relayStarting = false; @@ -282,16 +293,29 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { }, HEARTBEAT_INTERVAL_MS); heartbeat.unref(); + function currentTransport(): SessionTransportStatus { + if (!shared) return "local"; + if (relayStarting) return "connecting"; + if (!relayBridge) return "local"; + if (p2pPeerCount > 0) return "p2p"; + return relayConnected ? "relay" : "offline"; + } + function paintRestingTitle(): void { if (!env.hudEnabled) return; - setTitle(shared ? `wrapper • shared • ${sessionTag}` : ""); + setTitle( + formatSessionHud({ + role: "host", + sessionTag, + transport: currentTransport(), + p2pPeerCount, + }), + ); } - function announce(title: string, body: string): void { + function announce(_title: string, body: string): void { log.info(body); - if (env.hudEnabled) { - setTitle(title); - } + paintRestingTitle(); if (session.isIdle) inlineMessage(body); if (env.hudEnabled) { notifyOS("wrapper", body); @@ -321,6 +345,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { } relayStarting = true; + paintRestingTitle(); const code = generateShareCode(); try { // Persist the shared flag and the access-code hash before issuing the relay @@ -335,6 +360,11 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { sessionId, pty: session, enableP2P: env.p2pEnabled, + onTransportChange: (state) => { + relayConnected = state.relayConnected; + p2pPeerCount = state.p2pPeerCount; + paintRestingTitle(); + }, onOpen: () => { if (backend.status !== "ready") return; void backend.client @@ -403,6 +433,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { ); } finally { relayStarting = false; + paintRestingTitle(); } }; @@ -410,6 +441,9 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { if (!relayBridge) return; const bridge = relayBridge; relayBridge = null; + relayConnected = false; + p2pPeerCount = 0; + paintRestingTitle(); await bridge.stop(); if (backend.status === "ready") { await backend.client @@ -467,13 +501,22 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { onArmedChange: (armed) => { if (!env.hudEnabled) return; if (armed) { - setTitle(`● wrapper armed • ${sessionTag}`); + setTitle( + formatSessionHud({ + role: "host", + sessionTag, + transport: currentTransport(), + p2pPeerCount, + armed: true, + }), + ); bell(); } else { paintRestingTitle(); } }, }); + paintRestingTitle(); // Reuse attach-client path so host and viewer go through same protocol. @@ -484,14 +527,36 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { connectRetries: 20, connectRetryDelayMs: 50, interceptStdin: (chunk) => prefixFilter.process(chunk), + onTerminalTitle: paintRestingTitle, }); + // Print the control discovery hint once the shell reaches an idle prompt. We + // never write it while a foreground program owns the terminal, so a slow shell + // startup or a full-screen TUI cannot be corrupted. + let controlsHintTimer: ReturnType | null = null; + let controlsHintAttempts = 0; + const showControlsHint = (): void => { + controlsHintTimer = null; + if (session.isIdle) { + inlineMessage(formatControlsHint("host")); + paintRestingTitle(); + return; + } + controlsHintAttempts += 1; + if (controlsHintAttempts >= 20) return; + controlsHintTimer = setTimeout(showControlsHint, 250); + controlsHintTimer.unref?.(); + }; + controlsHintTimer = setTimeout(showControlsHint, 500); + controlsHintTimer.unref?.(); + let shuttingDown = false; const shutdown = async (reason: ShutdownReason): Promise => { if (shuttingDown) return 0; shuttingDown = true; log.debug("shell-host shutting down", { sessionId, reason }); clearInterval(heartbeat); + if (controlsHintTimer) clearTimeout(controlsHintTimer); authRefresh?.stop(); await stopRelayBridge(); if (backend.status === "ready") { diff --git a/apps/cli/relay/host-bridge.ts b/apps/cli/relay/host-bridge.ts index f8d6da8..d6a4f1d 100644 --- a/apps/cli/relay/host-bridge.ts +++ b/apps/cli/relay/host-bridge.ts @@ -13,11 +13,13 @@ export interface RelayHostBridgeOptions { ticket: string; sessionId: SessionId; pty: PtySession; - /** Opt-in: negotiate direct P2P data channels with viewers (relay fallback). */ + /** Negotiate direct P2P data channels with viewers (relay fallback). */ enableP2P?: boolean; onOpen?: () => void; onClose?: () => void; onError?: (error: Error) => void; + /** Reports relay connectivity and the number of live direct viewer channels. */ + onTransportChange?: (state: { relayConnected: boolean; p2pPeerCount: number }) => void; } export interface RelayHostBridge { @@ -33,12 +35,22 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri // unless enableP2P; output is fanned out to these in addition to the relay. const p2pPeers = new Map(); const p2pChannels = new Map(); + let relayConnected = false; + + const reportTransport = (): void => { + opts.onTransportChange?.({ + relayConnected, + p2pPeerCount: [...p2pChannels.values()].filter((channel) => channel.isOpen).length, + }); + }; // The relay WebSocket is the transport and the WebRTC signaling channel. When // enableP2P is set, `handleSignal` negotiates per-viewer data channels and // `send` fans output to them; otherwise everything flows over this socket. const transport: Transport = new WebSocketTransport(wsUrl, { onOpen: () => { + relayConnected = true; + reportTransport(); log.info("relay host connected", { sessionId: opts.sessionId }); opts.onOpen?.(); send({ @@ -58,6 +70,8 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri handleInbound(msg); }, onClose: (info) => { + relayConnected = false; + reportTransport(); opts.onClose?.(); if (!closed) { log.warn("relay host disconnected", { @@ -68,6 +82,8 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri } }, onError: (info) => { + relayConnected = false; + reportTransport(); opts.onError?.(new Error("relay websocket error")); log.warn("relay host websocket error", { sessionId: opts.sessionId, @@ -135,6 +151,7 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri p2pPeers.get(peerId)?.negotiation.cancel(); p2pPeers.delete(peerId); p2pChannels.delete(peerId); + reportTransport(); return; } let entry = p2pPeers.get(peerId); @@ -154,9 +171,13 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri }, onClose: () => { p2pChannels.delete(peerId); + p2pPeers.delete(peerId); + reportTransport(); }, onError: () => { p2pChannels.delete(peerId); + p2pPeers.delete(peerId); + reportTransport(); }, }, sendSignal: ({ kind, data }) => { @@ -179,7 +200,11 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri const t = await negotiation.transport; if (t) { p2pChannels.set(peerId, t); + reportTransport(); log.info("p2p data channel up (host)", { sessionId: opts.sessionId, peerId }); + } else { + p2pPeers.delete(peerId); + reportTransport(); } })(); } @@ -194,6 +219,8 @@ export function startRelayHostBridge(opts: RelayHostBridgeOptions): RelayHostBri for (const entry of p2pPeers.values()) entry.negotiation.cancel(); p2pPeers.clear(); p2pChannels.clear(); + relayConnected = false; + reportTransport(); transport.close(); } diff --git a/apps/cli/tests/feedback.test.ts b/apps/cli/tests/feedback.test.ts new file mode 100644 index 0000000..1987dc9 --- /dev/null +++ b/apps/cli/tests/feedback.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { formatControlsHint, formatSessionHud } from "../util/feedback"; + +describe("session HUD", () => { + test("formats persistent host state", () => { + expect( + formatSessionHud({ + role: "host", + sessionTag: "ABC123", + transport: "local", + }), + ).toBe("wrapper • host • ABC123 • local"); + }); + + test("formats host prefix controls with P2P peer count", () => { + expect( + formatSessionHud({ + role: "host", + sessionTag: "ABC123", + transport: "p2p", + p2pPeerCount: 2, + armed: true, + }), + ).toBe("● host • ABC123 • p2p x2 | s share • u unshare • ? status"); + }); + + test("formats viewer prefix controls", () => { + expect( + formatSessionHud({ + role: "viewer", + sessionTag: "XYZ789", + transport: "relay", + armed: true, + }), + ).toBe("● viewer • XYZ789 • relay | d detach • ? status"); + }); + + test("provides discoverability hints for both roles", () => { + expect(formatControlsHint("host")).toContain("Ctrl+\\"); + expect(formatControlsHint("host")).toContain("s share"); + expect(formatControlsHint("viewer")).toContain("d detach"); + }); +}); diff --git a/apps/cli/transport/transport.ts b/apps/cli/transport/transport.ts index a812d4f..64ae927 100644 --- a/apps/cli/transport/transport.ts +++ b/apps/cli/transport/transport.ts @@ -2,8 +2,8 @@ * Transport abstraction for the wrapper wire protocol. * * Host and viewer speak the same `@repo/protocol` frames; only the underlying - * channel differs. Today that channel is a WebSocket to the relay; next it will - * be a WebRTC data channel for direct P2P (with the relay kept as fallback). + * channel differs. It can be a WebSocket to the relay or a WebRTC data channel + * for direct P2P, with the relay kept as signaling and fallback. * Keeping the pipe dumb (opaque string/binary frames in, callbacks out) lets the * host/viewer code stay transport-agnostic. */ diff --git a/apps/cli/transport/webrtc.ts b/apps/cli/transport/webrtc.ts index 61cb95a..d21589b 100644 --- a/apps/cli/transport/webrtc.ts +++ b/apps/cli/transport/webrtc.ts @@ -15,6 +15,7 @@ const ICE_SERVERS = [ { urls: "stun:global.stun.twilio.com:3478" }, ]; const DEFAULT_TIMEOUT_MS = 8000; +const DISCONNECTED_GRACE_MS = 3000; const DATA_CHANNEL_LABEL = "wrapper"; export type SignalKind = SignalMessage["kind"]; @@ -87,29 +88,71 @@ class DataChannelTransport implements Transport { export function negotiateWebRtc(opts: NegotiateOptions): Negotiation { const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); let settled = false; + let stopped = false; + let channelTransport: DataChannelTransport | null = null; + let connectionState = "new"; + let timer: ReturnType | null = null; + let disconnectedTimer: ReturnType | null = null; + let remoteDescriptionReady = false; + const pendingIce: unknown[] = []; let resolveTransport!: (t: Transport | null) => void; const transport = new Promise((resolve) => { resolveTransport = resolve; }); + const clearTimers = (): void => { + if (timer) clearTimeout(timer); + if (disconnectedTimer) clearTimeout(disconnectedTimer); + timer = null; + disconnectedTimer = null; + }; + const finish = (t: Transport | null): void => { if (settled) return; settled = true; + if (timer) clearTimeout(timer); + timer = null; resolveTransport(t); }; - const timer = setTimeout(() => { - if (!settled) { - log.debug("webrtc negotiation timed out; falling back to relay", { role: opts.role }); - finish(null); - void pc.close(); - } + const flushPendingIce = async (): Promise => { + const candidates = pendingIce.splice(0); + await Promise.all(candidates.map((candidate) => pc.addIceCandidate(candidate as never))); + }; + + const stopPeer = (): void => { + if (stopped) return; + stopped = true; + clearTimers(); + channelTransport?.markOpen(false); + finish(null); + void pc.close(); + }; + + const failPeer = (message: string): void => { + if (stopped) return; + const wasOpen = channelTransport?.isOpen ?? false; + stopped = true; + clearTimers(); + channelTransport?.markOpen(false); + finish(null); + // Before opening, resolving null is enough: callers stay on the relay. Once + // open, notify them immediately so input switches back to the relay instead + // of being sent into a dead data channel. + if (wasOpen) opts.handlers.onError?.({ message }); + else log.debug("webrtc negotiation failed; using relay fallback", { role: opts.role, message }); + void pc.close(); + }; + + timer = setTimeout(() => { + if (settled || stopped) return; + log.debug("webrtc negotiation timed out; falling back to relay", { role: opts.role }); + stopPeer(); }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); timer.unref?.(); const closePeer = (): void => { - clearTimeout(timer); - void pc.close(); + stopPeer(); }; const wireChannel = (channel: { @@ -120,18 +163,26 @@ export function negotiateWebRtc(opts: NegotiateOptions): Negotiation { onmessage?: ((ev: { data: unknown }) => void) | undefined; }): void => { const dt = new DataChannelTransport(channel, closePeer, channel.readyState === "open"); + channelTransport = dt; channel.onopen = () => { + if (stopped) return; dt.markOpen(true); - clearTimeout(timer); + if (timer) clearTimeout(timer); + timer = null; opts.handlers.onOpen?.(); finish(dt); }; channel.onclose = () => { dt.markOpen(false); + if (stopped) return; + stopped = true; + clearTimers(); opts.handlers.onClose?.({}); finish(null); + void pc.close(); }; channel.onmessage = (ev) => { + if (stopped) return; const d = ev.data; if (typeof d === "string") { opts.handlers.onMessage?.(d); @@ -153,9 +204,27 @@ export function negotiateWebRtc(opts: NegotiateOptions): Negotiation { }); pc.connectionStateChange.subscribe((state) => { - if (state === "failed" || state === "closed") { - opts.handlers.onError?.({ message: `peer connection ${state}` }); - finish(null); + connectionState = state; + if (state === "connected") { + if (disconnectedTimer) clearTimeout(disconnectedTimer); + disconnectedTimer = null; + return; + } + if (state === "disconnected") { + if (disconnectedTimer) return; + disconnectedTimer = setTimeout(() => { + disconnectedTimer = null; + if (connectionState !== "connected") { + failPeer("peer connection disconnected"); + } + }, DISCONNECTED_GRACE_MS); + disconnectedTimer.unref?.(); + return; + } + if (state === "failed") { + failPeer("peer connection failed"); + } else if (state === "closed" && !stopped) { + failPeer("peer connection closed"); } }); @@ -169,8 +238,7 @@ export function negotiateWebRtc(opts: NegotiateOptions): Negotiation { opts.sendSignal({ kind: "offer", data: JSON.stringify(pc.localDescription) }); } catch (err) { log.warn("failed to create webrtc offer", { error: (err as Error).message }); - finish(null); - void pc.close(); + failPeer("failed to create WebRTC offer"); } })(); } else { @@ -180,23 +248,36 @@ export function negotiateWebRtc(opts: NegotiateOptions): Negotiation { } const acceptSignal = (kind: SignalKind, data: string): void => { + if (stopped) return; void (async () => { try { if (kind === "offer") { await pc.setRemoteDescription(JSON.parse(data)); + remoteDescriptionReady = true; + await flushPendingIce(); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); opts.sendSignal({ kind: "answer", data: JSON.stringify(pc.localDescription) }); } else if (kind === "answer") { await pc.setRemoteDescription(JSON.parse(data)); + remoteDescriptionReady = true; + await flushPendingIce(); } else if (kind === "ice") { - await pc.addIceCandidate(JSON.parse(data)); + const candidate: unknown = JSON.parse(data); + if (remoteDescriptionReady) { + await pc.addIceCandidate(candidate as never); + } else { + // ICE callbacks may fire before the offer/answer frame is sent. Queue + // candidates until the remote description exists instead of dropping + // them with an InvalidStateError. + pendingIce.push(candidate); + } } else if (kind === "bye") { - finish(null); - void pc.close(); + stopPeer(); } } catch (err) { log.warn("failed to apply webrtc signal", { kind, error: (err as Error).message }); + if (kind !== "ice") failPeer(`failed to apply WebRTC ${kind}`); } })(); }; @@ -204,9 +285,6 @@ export function negotiateWebRtc(opts: NegotiateOptions): Negotiation { return { transport, acceptSignal, - cancel: () => { - finish(null); - closePeer(); - }, + cancel: stopPeer, }; } diff --git a/apps/cli/util/feedback.ts b/apps/cli/util/feedback.ts index 44874c8..43aa50f 100644 --- a/apps/cli/util/feedback.ts +++ b/apps/cli/util/feedback.ts @@ -36,6 +36,42 @@ import { env } from "./env"; const ESC = "\x1b"; const BEL = "\x07"; +export type SessionHudRole = "host" | "viewer"; +export type SessionTransportStatus = "local" | "connecting" | "relay" | "p2p" | "offline"; + +export interface SessionHudState { + role: SessionHudRole; + sessionTag: string; + transport: SessionTransportStatus; + armed?: boolean; + p2pPeerCount?: number; +} + +/** + * Build the persistent window title and the context-aware prefix menu. + * + * We deliberately use the terminal title instead of reserving a bottom row. + * Wrapper passes PTY output through unchanged, so a fixed in-terminal status + * line would fight alternate-screen apps such as vim, less, htop, and tmux. + */ +export function formatSessionHud(state: SessionHudState): string { + const transport = + state.transport === "p2p" && state.role === "host" && (state.p2pPeerCount ?? 0) > 0 + ? `p2p x${state.p2pPeerCount}` + : state.transport; + const identity = `${state.role} • ${state.sessionTag} • ${transport}`; + if (!state.armed) return `wrapper • ${identity}`; + const commands = state.role === "host" ? "s share • u unshare • ? status" : "d detach • ? status"; + return `● ${identity} | ${commands}`; +} + +/** One-time discoverability hint printed before the terminal becomes busy. */ +export function formatControlsHint(role: SessionHudRole): string { + return role === "host" + ? "controls: Ctrl+\\ then s share | u unshare | ? status" + : "controls: Ctrl+\\ then d detach | ? status"; +} + /** * Set the host terminal's window title via OSC 0. Strips control * characters so a malicious or buggy state value can't smuggle a From f4dab337994015ee954fce82a7b951293b993039 Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Mon, 27 Jul 2026 14:25:40 +0300 Subject: [PATCH 7/7] docs: explain session HUD, fallback behavior, and planned visuals Document the discoverable controls, transport state and hardened fallback path, add detailed non-rendered image TODOs where visual assets will help, and fix the docs dark-theme contrast so the accessibility check passes. --- README.md | 14 ++++++++------ apps/cli/README.md | 24 ++++++++++++++++++++++-- apps/cli/transport/README.md | 25 ++++++++++++++++--------- apps/docs/README.md | 11 +++++++++++ apps/docs/architecture.mdx | 24 +++++++++++++++++++----- apps/docs/docs.json | 2 +- apps/docs/environments.mdx | 4 +++- apps/docs/installation.mdx | 2 ++ apps/docs/introduction.mdx | 13 +++++++------ apps/docs/mobile-plan.mdx | 2 ++ apps/docs/onboarding.mdx | 11 +++++++++++ apps/docs/release-channels.mdx | 2 ++ apps/docs/setup.mdx | 7 ++++++- apps/docs/transports.mdx | 26 +++++++++++++++++++++----- apps/docs/troubleshooting.mdx | 24 ++++++++++++++++++++++++ 15 files changed, 155 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 977fcfc..250eae7 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This is a Bun and Turborepo monorepo. ``` apps/ - cli/ Wrapper CLI: shell wrapping, session registry, local + relay attach, device auth, optional P2P + cli/ Wrapper CLI: shell wrapping, session registry, local + relay attach, device auth, default-on P2P relay/ Relay service: authenticated WebSocket routing for shared sessions, deployed on Fly.io web/ Next.js app on Vercel: landing page, device-login approval, onboarding, Pro upgrade docs/ Public documentation site (Mintlify) @@ -53,7 +53,7 @@ tools/ The CLI is the heart of the project. See [`apps/cli/README.md`](./apps/cli/README.md) for how the wrapping flow works and what every command does. The transport layer (relay WebSocket and the -optional direct WebRTC path) is documented in +default direct WebRTC path) is documented in [`apps/cli/transport/README.md`](./apps/cli/transport/README.md). ## Where to read next @@ -73,10 +73,12 @@ optional direct WebRTC path) is documented in ## Status The CLI core, the Convex auth and backend, the relay transport, and the web -onboarding flow are implemented in this repository. Sharing runs over the relay -with an optional direct WebRTC P2P fast path (`WRAPPER_P2P`, off by default, with -the relay as the fallback). The next major phase is the mobile app, which lives -in the `apps/mobile` submodule and is planned in the docs site. +onboarding flow are implemented in this repository. Sharing attempts a direct +WebRTC P2P data path by default, with the relay kept online for signaling and +automatic fallback (`WRAPPER_P2P=0` forces relay-only mode). The terminal title +shows the role, session, and active transport without reserving a screen row. +The next major phase is the mobile app, which lives in the `apps/mobile` +submodule and is planned in the docs site. The active focus before mobile work is operational hardening and release channels: diff --git a/apps/cli/README.md b/apps/cli/README.md index 355b0c1..47b4f70 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -84,6 +84,8 @@ sequenceDiagram - periodic tick -> `session:heartbeat` - shutdown -> `session:close` 8. On `share`, it issues a relay host ticket and starts a relay bridge. +9. It keeps a non-disruptive session HUD in the terminal title and reveals the + context-specific controls when `Ctrl+\` is armed. Important safety guards: @@ -142,6 +144,12 @@ Full design, security model, and testing steps live in attaches; local `127.0.0.1` attaches are already direct. A direct connection exposes each peer's IP to the other, so opt out if that matters for a session. +The active path is visible in the session HUD as `local`, `connecting`, `relay`, +`p2p`, or `offline`. If a P2P channel fails or stays disconnected, the viewer +switches input and output back to the relay immediately. ICE candidates that +arrive before the SDP offer or answer are queued instead of dropped. A live P2P +channel can also keep running if the signaling WebSocket closes. + ## Sharing and access Sharing is capability-based, so only people you explicitly invite can join: @@ -229,6 +237,18 @@ bun run dev:host ## In-session prefix shortcuts +Wrapper prints a one-time controls hint when a host reaches its first idle +prompt and when a viewer attaches. The terminal title then stays updated with +the role, short session id, and active transport. Pressing `Ctrl+\` changes the +title into a context-aware controls menu for 1.5 seconds. + +Wrapper does not reserve a permanent row inside the terminal. A fixed bottom +bar would conflict with alternate-screen applications such as Vim, Neovim, +`less`, `htop`, and tmux. The title-based HUD remains visible without changing +PTY output, shell prompts, or full-screen layouts. If a shell or TUI writes its +own OSC title, Wrapper repaints the session HUD immediately afterward. Set +`WRAPPER_HUD=off` to disable title updates. + Inside host shell: | Keys | Action | @@ -284,7 +304,7 @@ share code, and enables remote attach with | `WRAPPER_TELEMETRY_URL` | telemetry endpoint | `https://telemetry.wrapper.sh` | | `WRAPPER_RELAY_URL` | relay endpoint override | dev localhost, prod `wss://relay.wrapper.sh` | | `WRAPPER_AUTH_ORIGIN` | auth callback origin | dev localhost, prod `https://wrapper.sh` | -| `WRAPPER_HUD` | HUD (`on/off`) | `on` | +| `WRAPPER_HUD` | session title + armed controls HUD | `on` | | `WRAPPER_P2P` | WebRTC P2P fast path; `0/false/off` opts out | on (relay is the fallback) | | `WRAPPER_CONVEX_URL` | Convex deployment URL for backend | falls back to `CONVEX_URL` if set | | `WRAPPER_DISABLE` | disable hook in one terminal | unset | @@ -318,7 +338,7 @@ relay/ host-bridge.ts host relay bridge; per-viewer P2P negotiation transport/ transport.ts Transport interface + WebSocketTransport - webrtc.ts WebRTC P2P transport (werift, opt-in) + webrtc.ts WebRTC P2P transport (werift, default-on) README.md transport + P2P design/security/testing shell/ detect.ts shell detection diff --git a/apps/cli/transport/README.md b/apps/cli/transport/README.md index 55b118a..143fe2a 100644 --- a/apps/cli/transport/README.md +++ b/apps/cli/transport/README.md @@ -46,8 +46,9 @@ construction. Encoding/parsing stays in the host/viewer, not the transport. - **Signaling rides the relay.** SDP offer/answer and ICE candidates are carried as `signal` protocol frames over the existing relay WebSocket (see `apps/relay/src/hub.ts`). No extra signaling server. -- **Discovery:** public STUN servers. The relay is the TURN-equivalent: if ICE - can't connect within a timeout, the session transparently stays on the relay. +- **Discovery:** the existing public Google and Twilio STUN servers. No TURN + service or new third-party dependency was added. The authenticated Wrapper + relay is the data fallback if ICE cannot establish a direct path. - **`negotiateWebRtc()`** returns a `Negotiation` whose `transport` promise resolves with a data-channel `Transport` once the channel opens, or `null` on timeout/failure (so callers fall back). @@ -77,15 +78,21 @@ keeps one peer connection per viewer. viewers get the relay copy; P2P viewers get the data-channel copy. - **Viewer** prefers the data channel for `input`/`resize`, and **dedups**: once its data channel is open it ignores the relay's duplicate `output` frames. -- If the data channel drops, the viewer/host revert to the relay automatically. +- If the data channel drops or stays disconnected for three seconds, the viewer + marks it closed and resumes input/output over the relay immediately. +- ICE candidates received before the SDP offer/answer are queued until the + remote description exists instead of being dropped. +- If the signaling WebSocket closes after P2P is open, the direct channel keeps + the session alive. If that channel later closes too, the attach ends because + no fallback remains. ## Security - **Encryption:** WebRTC data channels are DTLS-encrypted end-to-end. Frames never traverse the relay once P2P is established. -- **Authorization:** only a viewer that already passed the relay ticket auth - (`relay:issueViewerTicket`, which checks ownership/`shared`) can signal, so only - authorized peers can form a P2P connection. +- **Authorization:** only a viewer that already passed relay ticket auth can + signal. The owner can join their own session; a non-owner must present the + session's share code. Knowing a session id is not enough. - **No peer spoofing / cross-session:** the relay assigns an authoritative per-viewer `peerId` and stamps the `signal.from` field (ignoring client claims); host→viewer signals are routed strictly by `peerId` within the same session. @@ -115,12 +122,12 @@ exposes each peer's IP to the other, which is why the opt-out exists. - Unit-testable: the transport abstraction and the relay's signal routing (`apps/relay/tests/hub.test.ts`). - **NAT traversal must be verified on two real machines/networks**, since it cannot be - exercised in CI/sandbox. Because the flag defaults off and the relay is the - fallback, shipping this cannot regress the working relay path. + exercised in CI/sandbox. P2P is default-on, but the authenticated relay remains + connected as the working fallback. ## Files ```text transport.ts Transport interface + WebSocketTransport (relay/fallback) -webrtc.ts WebRtcDataChannelTransport + negotiateWebRtc (werift, opt-in) +webrtc.ts WebRtcDataChannelTransport + negotiateWebRtc (werift, default-on) ``` diff --git a/apps/docs/README.md b/apps/docs/README.md index 9f5678d..2912776 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -16,3 +16,14 @@ bun run build Content entrypoint: `introduction.mdx` Site config: `docs.json` + +## Planned images + +Pages contain non-rendered `{/* TODO(image): ... */}` comments at locations +where a screenshot or diagram materially improves understanding. Each comment +specifies the content, redaction requirements, suggested filename, and whether +the asset is essential or optional. + +Store completed assets in `apps/docs/images/` and reference them from MDX with a +root-relative path such as `/images/wrapper-session-overview.webp`. Always add +descriptive alt text, and remove the corresponding TODO when the asset lands. diff --git a/apps/docs/architecture.mdx b/apps/docs/architecture.mdx index 7dfd5f2..5143e99 100644 --- a/apps/docs/architecture.mdx +++ b/apps/docs/architecture.mdx @@ -5,8 +5,8 @@ description: Understand how CLI, backend, and relay work together. ## System components -- `apps/cli`: host shell, local attach, auth commands, relay bridge, optional - direct WebRTC transport +- `apps/cli`: host shell, local attach, auth commands, relay bridge, and the + default direct WebRTC transport - `packages/backend`: Convex auth, session lifecycle, relay tickets, onboarding, billing - `apps/relay`: authenticated WebSocket router for host and viewers, and the @@ -29,6 +29,8 @@ flowchart LR relay -->|broadcast output and close| viewerCli ``` +{/* TODO(image): Add /images/wrapper-architecture.webp here. Turn the flow above into a polished diagram with trust boundaries: local PTY/token, Convex auth and share-code gate, Fly relay signaling/fallback, and the encrypted P2P data path. Essential security and architecture asset. */} + ## Session lifecycle Backend handlers in `convex/session.ts`: @@ -38,6 +40,7 @@ Backend handlers in `convex/session.ts`: - `close` - `listActive` - `authorizeAttach` +- `setShareCode` - `setRelayState` Onboarding handlers in `convex/onboarding.ts`: @@ -74,8 +77,9 @@ Relay routing in `apps/relay/src/hub.ts`: Terminal traffic can move over two transports, both abstracted behind the `Transport` interface in `apps/cli/transport`: -- **Relay WebSocket** (default): frames travel host to relay to viewer. -- **Direct WebRTC** (on by default, opt out with `WRAPPER_P2P=0`): the relay +- **Relay WebSocket** (baseline): frames travel host to relay to viewer. It is + always available for signaling and fallback. +- **Direct WebRTC** (default data path, opt out with `WRAPPER_P2P=0`): the relay carries only the offer, answer, and ICE candidates, and the terminal bytes flow over a direct peer-to-peer data channel. If negotiation fails, the relay path keeps working. @@ -98,6 +102,16 @@ Once viewers join, control is shared (every viewer can type), the terminal is sized to the smallest viewer, and there is one host per session. See the CLI README "Sharing and access" section for the full behavior list. +## Session HUD + +The CLI uses the terminal window title for persistent role, short session id, +and transport status (`local`, `connecting`, `relay`, `p2p`, or `offline`). +Pressing `Ctrl+\` replaces the title with context-specific controls for the +1.5-second armed window. A fixed bottom row is intentionally avoided because it +would conflict with alternate-screen applications and change the PTY viewport. +Wrapper repaints its HUD after a shell or TUI emits an OSC title sequence, so +the status does not disappear during normal terminal use. + ## Billing `convex/billing.ts` exposes `createProCheckout`, which returns a Stripe checkout @@ -109,7 +123,7 @@ cannot break sharing. ```text apps/ - cli/ shell wrapping, local + relay attach, device auth, optional P2P + cli/ shell wrapping, local + relay attach, device auth, default-on P2P relay/ authenticated WebSocket router on Fly.io web/ Next.js app on Vercel docs/ this documentation site (Mintlify) diff --git a/apps/docs/docs.json b/apps/docs/docs.json index e957527..c504b7f 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -5,7 +5,7 @@ "colors": { "primary": "#6E56CF", "light": "#9B8AE8", - "dark": "#5B45B0" + "dark": "#6E56CF" }, "background": { "color": { diff --git a/apps/docs/environments.mdx b/apps/docs/environments.mdx index 7a526b4..b95d7ef 100644 --- a/apps/docs/environments.mdx +++ b/apps/docs/environments.mdx @@ -21,12 +21,14 @@ overview. Two long-lived environments, selected by branch: -``` +```text local your machine Convex dev + ws://localhost:8080 + localhost:3000 dev branch -> DEV env -> Convex dev deployment, wrapper-relay-dev, Vercel Preview main branch -> PROD env -> Convex prod deployment, wrapper-relay-prod, Vercel Production ``` +{/* TODO(image): Add /images/environment-promotion-flow.webp here. Show local, dev, and production lanes with the branches and their Convex, Fly, and Vercel targets. Do not include secret values. Optional operator asset. */} + ## How each component picks dev vs prod - **web (Vercel)**: Vercel's Git integration decides by the Production Branch diff --git a/apps/docs/installation.mdx b/apps/docs/installation.mdx index 169df20..bb2ea65 100644 --- a/apps/docs/installation.mdx +++ b/apps/docs/installation.mdx @@ -25,6 +25,8 @@ On macOS you can also use the Homebrew tap: brew install heycupola/tap/wrapper ``` +{/* TODO(image): Add /images/cli-install-success.webp here. Show a clean install followed by `wrapper --version` and the first controls hint in a new terminal. Capture both arm64 macOS and Linux only if the output differs. Optional. */} + See [Release channels](/release-channels) for how archives, checksums, and the Homebrew formula are produced. diff --git a/apps/docs/introduction.mdx b/apps/docs/introduction.mdx index cbaff50..67a2c50 100644 --- a/apps/docs/introduction.mdx +++ b/apps/docs/introduction.mdx @@ -8,6 +8,8 @@ mirror it on demand. Your shell stays local until you choose to share it, and the wrapping is invisible: your dotfiles, prompt, plugins, and history behave exactly as before. +{/* TODO(image): Add /images/wrapper-session-overview.webp here. Show a host terminal pressing Ctrl+\ then s, the generated share code, and a remote viewer connected over P2P. Include both desktop and phone in one composition; redact real usernames, paths, and tokens. Essential product overview asset. */} + ## The parts - **CLI** (`apps/cli`): hosts your shell inside a PTY, runs a local WebSocket @@ -28,18 +30,17 @@ exactly as before. exposed. 2. `wrapper attach` mirrors that session from the same machine with no account and no network. -3. Pressing `Ctrl+\ s` shares the session over the relay. Another device joins - with `wrapper attach --relay`. -4. With `WRAPPER_P2P=1` on both ends, keystrokes and output travel over a direct - WebRTC data channel for lower latency, and the relay is used only for - signaling and as a fallback. +3. Pressing `Ctrl+\ s` shares the session and prints an access code. Your own + devices can join directly; another user must supply that code. +4. Wrapper attempts a direct WebRTC data channel by default. The relay carries + signaling and remains connected as the automatic fallback. ## Current status The platform is complete up to mobile app development: - CLI host and attach flow (local and relay) -- Optional direct WebRTC P2P fast path +- Default-on direct WebRTC P2P fast path with relay fallback - Device authorization login flow - Web onboarding for first-run setup - Backend session lifecycle and access control diff --git a/apps/docs/mobile-plan.mdx b/apps/docs/mobile-plan.mdx index b044c55..68eca9b 100644 --- a/apps/docs/mobile-plan.mdx +++ b/apps/docs/mobile-plan.mdx @@ -20,3 +20,5 @@ is already defined: - repo linkage strategy: submodule under `apps/mobile` - auth bootstrapping via Better Auth session handoff - relay viewer mode first, host mode later + +{/* TODO(image): Add /images/mobile-viewer-wireframes.webp here. Include the signed-in session list, share-code join screen, terminal viewer with role/transport status, and detach controls. Use wireframes until the mobile UI is implemented. Essential before mobile design review. */} diff --git a/apps/docs/onboarding.mdx b/apps/docs/onboarding.mdx index e641f62..f25a827 100644 --- a/apps/docs/onboarding.mdx +++ b/apps/docs/onboarding.mdx @@ -13,6 +13,8 @@ wrapper auth login Open the displayed URL, sign in with GitHub/Google, and approve the code. +{/* TODO(image): Add /images/device-auth-approval.webp here. Capture the browser approval page with a sample user code and the signed-in account visible; use test data and redact session cookies or browser extensions. Essential onboarding asset. */} + ## 2) Complete web onboarding After approving the code, continue to `/onboarding` in the web app. @@ -23,6 +25,8 @@ Checklist: - confirm CLI login is working - share your first session +{/* TODO(image): Add /images/web-onboarding-checklist.webp here. Show the onboarding checklist with profile, CLI connection, and first-share progress. Use a test account. Optional. */} + ## 3) Start your host session ```bash @@ -31,6 +35,11 @@ wrapper shell-host This is your owner terminal. +The terminal title shows `host`, the short session id, and the active transport. +Wrapper prints the controls once the first prompt is idle. Press `Ctrl+\` at any +time to reveal the context-specific controls in the title without changing the +terminal screen. + ## 4) Share and attach In host terminal: @@ -54,6 +63,8 @@ wrapper attach --relay --id --code Every viewer who joins can type into the same shell, so only share the code with people you want to co-drive the session. +{/* TODO(image): Add /images/share-code-and-viewer.webp here. Show the host after Ctrl+\ then s with a fake share code and exact attach command next to a viewer whose title reports p2p. Redact the real session id and filesystem paths. Essential sharing asset. */} + ## 5) Done Once these steps are complete, onboarding is marked as finished for your account. diff --git a/apps/docs/release-channels.mdx b/apps/docs/release-channels.mdx index 1cedae5..0efee6b 100644 --- a/apps/docs/release-channels.mdx +++ b/apps/docs/release-channels.mdx @@ -19,6 +19,8 @@ description: How Wrapper CLI is released and distributed. Then `update-homebrew-tap.yml` updates `Formula/wrapper.rb` in the shared tap. +{/* TODO(image): Add /images/github-release-assets.webp here. Capture a real Wrapper release showing all three platform archives plus checksums.txt, followed by a successful Homebrew install check. Redact repository tokens and workflow secrets. Optional release asset. */} + ## Required GitHub secrets - `HOMEBREW_TAP_TOKEN` for committing formula updates diff --git a/apps/docs/setup.mdx b/apps/docs/setup.mdx index fec65c6..7ec093a 100644 --- a/apps/docs/setup.mdx +++ b/apps/docs/setup.mdx @@ -10,7 +10,8 @@ description: Configure environment variables, run checks, and verify your local - `WRAPPER_CONVEX_URL`: Convex deployment URL - `WRAPPER_RELAY_URL`: Relay WebSocket base URL - `WRAPPER_AUTH_ORIGIN`: Auth UI origin -- `WRAPPER_HUD`: `on` by default, set `off` to disable host HUD/title updates +- `WRAPPER_HUD`: `on` by default. The terminal title shows role, session, active + transport, and armed controls; set `off` to disable it. - `WRAPPER_P2P`: WebRTC direct path, on by default with automatic relay fallback. Set to `0` to force the relay. See [Transports](/transports). - `NODE_ENV=development`: local development mode @@ -35,6 +36,8 @@ description: Configure environment variables, run checks, and verify your local For the full per-environment matrix (Convex, Fly, Vercel, and GitHub Actions), see [Environments](/environments). +{/* TODO(image): Add /images/local-dev-terminals.webp here. Show the recommended local layout with Convex, relay, web, and CLI running in four labeled terminal panes. Use fake secrets and a development session. Optional developer asset. */} + ## Local quality checks From repo root: @@ -69,3 +72,5 @@ This validates: 4. Find id with `wrapper status` 5. Attach via relay from another terminal 6. Detach (`Ctrl+\\` then `d`) and unshare (`Ctrl+\\` then `u`) +7. Confirm the viewer title moves from `relay` to `p2p`, then repeat with + `WRAPPER_P2P=0` and confirm it stays on `relay` diff --git a/apps/docs/transports.mdx b/apps/docs/transports.mdx index bcef6b5..0e00d97 100644 --- a/apps/docs/transports.mdx +++ b/apps/docs/transports.mdx @@ -43,20 +43,28 @@ existing relay WebSocket as `signal` protocol frames, so there is no separate signaling server. Discovery uses public STUN servers, and the relay is the fallback if ICE cannot connect in time. +{/* TODO(image): Add /images/p2p-versus-relay.webp here. Show two paths side by side: direct encrypted host-to-viewer P2P and host-to-Fly-relay-to-viewer fallback. Label signaling separately from terminal data and include the `p2p`/`relay` HUD states. Essential transport asset. */} + ## Data path once P2P is up - The host fans PTY output to the relay and to every open data channel. Relay viewers get the relay copy, and P2P viewers get the data-channel copy. - The viewer prefers the data channel for input and resize, and once its channel is open it ignores the relay's duplicate output frames. -- If the data channel drops, both sides revert to the relay automatically. +- ICE candidates received before the SDP offer/answer are queued until the + remote description is ready. +- If the data channel drops or remains disconnected for three seconds, the + viewer immediately resumes input/output over the relay. +- If the signaling WebSocket closes after P2P is open, the data channel keeps + the session alive. If both paths close, the attach ends. ## Security - Data channels are DTLS-encrypted end to end. Once P2P is established, frames never traverse the relay. -- Only a viewer that already passed relay ticket auth (which checks ownership or - a shared session) can signal, so only authorized peers form a P2P connection. +- Only a viewer that already passed relay ticket auth can signal. The owner is + allowed; a non-owner must present the session's share code. Knowing the + session id alone is not enough. - The relay assigns an authoritative per-viewer peer id and stamps the `signal.from` field, ignoring client claims. Host to viewer signals are routed strictly by peer id within the same session, and signal payloads are capped at @@ -80,6 +88,12 @@ WRAPPER_P2P=0 wrapper attach --relay -i -c Look for `p2p data channel up` in both logs. P2P applies only to relay attaches; local `127.0.0.1` attaches are already direct. +No TURN service or new third-party dependency is required for fallback. Wrapper +keeps its authenticated WebSocket relay available as the fallback data path. +Candidate discovery still uses the configured public Google and Twilio STUN +servers. A dedicated TURN deployment can be added later to improve direct-path +success behind restrictive symmetric NAT, but it is not required for correctness. + ## Why werift The CLI ships as a single compiled binary (`bun build --compile`). A native @@ -92,5 +106,7 @@ and mobile viewers use their own native WebRTC. The transport abstraction and the relay signal routing are unit tested (`apps/relay/tests/hub.test.ts`). Real NAT traversal must be verified on two machines on different networks, since it cannot run in CI or a sandbox. Because -the flag defaults off and the relay is the fallback, enabling P2P cannot regress -the working relay path. +P2P is default-on and the relay remains connected, failed negotiation does not +regress the working relay path. + +{/* TODO(image): Add /images/p2p-success-logs.webp here. Capture sanitized host and viewer logs with `p2p data channel up`, then a second capture showing `using relay fallback` after a forced P2P failure. Optional troubleshooting asset. */} diff --git a/apps/docs/troubleshooting.mdx b/apps/docs/troubleshooting.mdx index 292ecf8..b85d592 100644 --- a/apps/docs/troubleshooting.mdx +++ b/apps/docs/troubleshooting.mdx @@ -37,6 +37,30 @@ fly status --app If image/version is consistent and health checks pass, deploy is generally okay. +## P2P does not come up + +The terminal title starts at `relay` and changes to `p2p` when the direct data +channel opens. If it stays on `relay`, the session is still working through the +authenticated Fly relay. + +1. Run `wrapper logs --follow` on host and viewer. +2. Confirm both sides use the same session and current CLI version. +3. Test from two real networks. CI and local sandbox tests cannot reproduce NAT. +4. Set `WRAPPER_P2P=0` to force relay-only mode while diagnosing. + +If an open P2P channel drops, Wrapper waits three seconds for a transient +disconnect to recover, then switches input/output back to the relay. Look for +`p2p data channel failed; using relay fallback` in the viewer log. + +{/* TODO(image): Add /images/transport-fallback-troubleshooting.webp here. Show a sanitized viewer log transitioning from p2p to relay fallback alongside the title changing to `relay`. Optional. */} + +## Controls are not visible + +Wrapper prints the available controls once and uses the terminal title as its +non-disruptive HUD. Press `Ctrl+\` to reveal the role-specific controls. If your +terminal hides titles, use `Ctrl+\` then `?` for an inline status line. Check +that `WRAPPER_HUD` is not set to `off`. + ## Web hydration mismatch warning If you see random attributes like `cz-shortcut-listen`, it is usually a browser