diff --git a/README.md b/README.md index 448aa16..250eae7 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 @@ -32,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) @@ -50,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 @@ -70,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/.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/README.md b/apps/cli/README.md index 92a6388..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: @@ -97,7 +99,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 +134,59 @@ 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. + +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: + +- 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. + +### 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 @@ -171,6 +219,7 @@ wrapper attach wrapper attach --id wrapper attach --port wrapper attach --relay --id +wrapper attach --relay --id --code wrapper logs --follow ``` @@ -188,12 +237,24 @@ 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 | | ----------------- | ------------------------- | -| `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 +266,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 +293,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` | 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 | +| `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. @@ -276,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/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 b8c6dca..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"); @@ -32,6 +40,7 @@ type AuthorizeAttachResponse = { type IssueViewerTicketArgs = { sessionId: string; + code?: string; }; type IssueViewerTicketResponse = { @@ -69,6 +78,7 @@ export interface AttachOptions { port?: number; host?: string; relay?: boolean; + code?: string; } const SIGINT_EXIT = 130; @@ -82,18 +92,39 @@ 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 - // 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`); - 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 @@ -118,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": @@ -136,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: { @@ -157,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 () => { @@ -199,7 +238,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 }; } @@ -208,7 +247,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 }; } @@ -221,7 +262,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); @@ -232,6 +273,7 @@ interface TargetSession { id: string; port?: number; local: boolean; + localToken?: string; } async function pickSession(sessions: SessionRecord[]): Promise { @@ -248,7 +290,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 { @@ -300,21 +342,23 @@ 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); 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 === "") { 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 +374,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 +391,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..7313628 100644 --- a/apps/cli/commands/shell-host.ts +++ b/apps/cli/commands/shell-host.ts @@ -9,9 +9,22 @@ 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 { + bell, + clearTitle, + formatControlsHint, + formatSessionHud, + inlineMessage, + notifyOS, + setTitle, + type SessionTransportStatus, +} from "../util/feedback"; import { installShutdownHandlers, type ShutdownReason } from "../util/signals"; const log = createLogger("shell-host"); @@ -91,6 +104,39 @@ const createProCheckoutRef = makeFunctionReference< { successUrl?: string }, { checkoutUrl: string } >("billing:createProCheckout"); +const setShareCodeRef = makeFunctionReference< + "mutation", + { sessionId: string; code?: string }, + { 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"; + +/** + * 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. @@ -106,6 +152,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { } const sessionId = createSessionId(); + const localToken = createLocalToken(); const initialSize = currentSize(); const session = new PtySession({ @@ -136,6 +183,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", { @@ -165,6 +213,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { shell: resolvedShell, createdAt: new Date().toISOString(), shared: false, + localToken, }); const backend = await resolveAuthedConvexClient(); @@ -188,12 +237,42 @@ 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; + 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; 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 @@ -204,21 +283,39 @@ 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); 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); @@ -248,15 +345,13 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { } relayStarting = true; + paintRestingTitle(); + 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({ @@ -265,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 @@ -284,8 +384,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 +400,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); @@ -333,6 +433,7 @@ export async function runShellHost(opts: ShellHostOptions = {}): Promise { ); } finally { relayStarting = false; + paintRestingTitle(); } }; @@ -340,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 @@ -366,12 +470,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 +484,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": @@ -394,31 +501,63 @@ 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. - 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, 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") { try { 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/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/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/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/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/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/README.md b/apps/cli/transport/README.md index 31be35e..143fe2a 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 @@ -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. @@ -96,30 +103,31 @@ 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 - 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/cli/transport/transport.ts b/apps/cli/transport/transport.ts index ee46e4f..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. */ @@ -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"; 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/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/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; 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 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 3e91dbf..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,13 +77,41 @@ 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** (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. +- **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. 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. + +## 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 @@ -92,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 e8696e4..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. @@ -76,8 +78,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/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 b9ba189..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,20 +35,36 @@ 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: -- `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. + +{/* 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 2f21472..7ec093a 100644 --- a/apps/docs/setup.mdx +++ b/apps/docs/setup.mdx @@ -10,9 +10,10 @@ 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_P2P`: set to `1` to opt in to the direct WebRTC path (off by default, - relay stays the fallback). See [Transports](/transports). +- `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 ### Relay @@ -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 ae87dad..0e00d97 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 @@ -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 @@ -67,19 +75,25 @@ 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; 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 diff --git a/packages/backend/README.md b/packages/backend/README.md index ba74c6d..93cd3c6 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 @@ -93,11 +97,35 @@ 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"`. +## 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 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); + }); +});