From e91d2cc7763830855cefdfc8dd723138c8e9c7bb Mon Sep 17 00:00:00 2001 From: Bin Jin Date: Tue, 16 Jun 2026 02:05:27 -0700 Subject: [PATCH] feat(collab): added QR links and split web UI URL support - Added collab.webUrl and rendered browser links as web UI wrappers whose fragments carry relay links. - Added one-shot /collab qrcode and /collab qrcode-view commands with terminal QR rendering. - Updated coding-agent and collab-web parsers to prefer parseable wrapper fragments while preserving legacy links. - Added regression tests and changelog entries for split-host collab links and QR commands. --- docs/collab.md | 29 ++- package.json | 2 + packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/package.json | 2 + packages/coding-agent/src/collab/host.ts | 8 +- packages/coding-agent/src/collab/protocol.ts | 63 +++++-- .../src/config/settings-schema.ts | 12 ++ .../src/slash-commands/builtin-registry.ts | 60 +++++-- .../slash-commands/helpers/collab-qrcode.ts | 29 +++ .../coding-agent/test/collab/crypto.test.ts | 81 +++++++++ .../test/slash-commands/collab-qrcode.test.ts | 167 ++++++++++++++++++ packages/collab-web/CHANGELOG.md | 4 + packages/collab-web/src/lib/link.ts | 13 +- packages/collab-web/test/link.test.ts | 32 ++++ 14 files changed, 465 insertions(+), 41 deletions(-) create mode 100644 packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts create mode 100644 packages/coding-agent/test/slash-commands/collab-qrcode.test.ts diff --git a/docs/collab.md b/docs/collab.md index e86a709a3..f69e9fc9a 100644 --- a/docs/collab.md +++ b/docs/collab.md @@ -20,6 +20,8 @@ Collab session started! The browser line is click-to-join (an OSC 8 hyperlink to the full `https://` deep link): the relay serves the web guest client at `/`, and the room id + key ride in the URL fragment. From another omp (any directory, any machine), either form works: +Run `/collab qrcode` to start or re-print hosting with a QR code for the full-control browser link; use `/collab qrcode-view` for a read-only QR code (`/collab qr-view` is a short alias). + ``` /join my.omp.sh/#mgAYTZwEnpRQtca0CTgn-Q.gdJU… ``` @@ -33,6 +35,8 @@ The guest's previous session is restored on `/leave` (or when the host stops). | `/collab` | Start sharing (or re-print the link when already hosting) | | `/collab ` | Start sharing through a specific relay (`relay.example.com`, `ws://localhost:7475`) | | `/collab view` | Print a read-only (view-only) link (starts sharing first if needed) | +| `/collab qrcode` | Print a QR code for a full-control browser link (starts sharing first if needed) | +| `/collab qrcode-view` | Print a QR code for a read-only browser link (starts sharing first if needed); `/collab qr-view` is an alias | | `/collab status` | Show link + participants | | `/collab stop` | Stop sharing | | `/join ` | Join a shared session as a guest | @@ -40,19 +44,29 @@ The guest's previous session is restored on `/leave` (or when the host stops). ## Link format +Accepted by `/join ` and `omp join ""`: + ``` -https://host[:port]/# → browser deep link (printed by /collab; /join accepts it too) -. → default relay (my.omp.sh) -host[:port]/r/. → custom relay, wss:// inferred -ws://localhost:7475/r/. → plain ws, allowed for localhost only +. → default relay (wss://my.omp.sh) +# → legacy bare form +host[:port]/r/. → custom relay, wss:// inferred +host[:port]/r/# → legacy direct relay form +https://host[:port]/r/. → direct relay URL, normalized to wss:// +wss://host[:port]/r/. → direct websocket relay URL +ws://localhost:7475/r/. → direct plain ws, localhost only +https://host[:port]/# → browser deep link when web UI and relay share a host +https://web-host[:port][/]/# → browser UI wrapper with relay link in the fragment +https://web.example/collab/#relay.example.com/r/. → web UI and relay on different hosts ``` -The trailing `.` part is the room secret, base64url-encoded, in one of two strengths: +`` / `` are parsed recursively as any accepted link above. For `http(s)` browser wrappers with a parseable fragment, the fragment wins before the HTTP host/path are treated as a relay. This lets `https://web.example/collab/#relay.example.com/r/.` open the web UI at `web.example` while joining `wss://relay.example.com/r/`. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so `https://relay.example.com/r/#` still means relay `relay.example.com`. + +The trailing `.` or `#` part is the room secret, base64url-encoded, in one of two strengths: - **Full link** — 48 bytes: the 32-byte AES-256-GCM room key followed by a 16-byte write token. Grants prompting, interrupting, and subagent control. - **View-only link** — the bare 32-byte key, no write token. Grants live read access only. Pre-token links parse as view-only. -The room secret is dot-joined rather than `#`-joined: RFC 3986 forbids a raw `#` inside a URL fragment, so strict URL stacks (macOS Foundation behind terminal click-to-open) percent-encode a second `#` to `%23` and break the link. Parsers leniently accept the legacy `#` form and the mangled `%23` form. In the browser deep link, everything after the `#` — room id and key — is a URL fragment: it never appears in any HTTP request, and neither secret is ever sent to the relay. +The room secret is dot-joined in newly generated links because RFC 3986 forbids a raw `#` inside a URL fragment; parsers still accept legacy `#` forms and `%23`-mangled legacy deep links. ## End-to-end encryption @@ -85,11 +99,14 @@ Known v1 limit for guests: a turn already streaming when you join becomes visibl `packages/collab-web` is a standalone browser client for the same links — no omp install needed on the guest side. The relay serves it at `/`, which is what makes the `/collab` deep link click-to-join: `https:///#` loads the client and auto-connects from the fragment. It renders the live transcript (streaming text, thinking, tool cards), a subagent panel with on-demand transcripts, and a composer with the same guest powers (prompt, interrupt, hub actions). Run `bun run dev` in the package for a local instance, `bun run mock-host` for an offline scripted host to develop against, and `bun run build` to emit a static `dist/` deployable anywhere (HTTPS required for WebCrypto). The client never talks to anything but the relay, and the key stays in the URL fragment. +Set `collab.webUrl` when the browser UI is hosted separately from the websocket relay. When empty, `/collab` derives `http(s)://host[:port]` from `collab.relayUrl`; explicit web UI URLs must use `https://` except for `http://localhost` development origins. The generated browser URL still carries the relay-specific collab link in the fragment. + ## Settings | Setting | Default | Meaning | |---|---|---| | `collab.relayUrl` | `wss://my.omp.sh` | Relay used by `/collab` when no relay is passed inline | +| `collab.webUrl` | empty | Browser UI URL for `/collab` links; empty derives from relay; explicit `http://` is allowed only for localhost | | `collab.displayName` | OS username | Name shown to other participants | | `share.serverUrl` | `https://my.omp.sh/s` | Share viewer/upload base used by `/share` (links are `/#`) | | `share.redactSecrets` | `true` | Run the secret obfuscator over `/share` snapshots before upload | diff --git a/package.json b/package.json index a61eab707..b60d9acf6 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/babel__generator": "^7.27.0", "@types/babel__traverse": "^7.28.0", "@types/bun": "^1.3.14", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/turndown": "5.0.6", @@ -75,6 +76,7 @@ "postcss": "^8.5.15", "prettier": "^3.8.4", "puppeteer-core": "^25.1.0", + "qrcode": "^1.5.4", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e7206c2ee..bf92601e1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -241,6 +241,10 @@ - Fixed task subagents to install their configured ordered model candidates as child-session retry fallback chains, so retryable provider failures can advance to the next subagent model instead of failing the worker ([#2750](https://github.com/can1357/oh-my-pi/issues/2750)). - Fixed empty reasonless aborted assistant turns to auto-retry without switching model fallback, so transient provider-side aborts after tool results do not end headless sessions ([#2685](https://github.com/can1357/oh-my-pi/issues/2685)). +### Added + +- Added `/collab qrcode` and `/collab qrcode-view` one-shot terminal QR output for browser join links and a `collab.webUrl` setting for separately hosted collab web clients. + ## [16.0.1] - 2026-06-15 ### Breaking Changes diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c13dfdea5..4eb5ae38f 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -66,6 +66,7 @@ "@opentelemetry/sdk-trace-base": "catalog:", "@opentelemetry/sdk-trace-node": "catalog:", "@puppeteer/browsers": "catalog:", + "@types/qrcode": "catalog:", "@types/turndown": "catalog:", "@xterm/headless": "catalog:", "arktype": "catalog:", @@ -78,6 +79,7 @@ "mammoth": "catalog:", "mupdf": "catalog:", "puppeteer-core": "catalog:", + "qrcode": "catalog:", "turndown": "catalog:", "turndown-plugin-gfm": "catalog:", "zod": "catalog:" diff --git a/packages/coding-agent/src/collab/host.ts b/packages/coding-agent/src/collab/host.ts index 55850839a..5f62f12ae 100644 --- a/packages/coding-agent/src/collab/host.ts +++ b/packages/coding-agent/src/collab/host.ts @@ -133,7 +133,7 @@ export class CollabHost { return this.#link; } - /** Browser deep link (`https:///#`) — the relay serves the web client at `/`. */ + /** Browser deep link for the configured collab web UI. */ get webLink(): string { return this.#webLink; } @@ -156,15 +156,15 @@ export class CollabHost { return list; } - async start(relayUrl: string): Promise { + async start(relayUrl: string, webUrl = ""): Promise { const rawKey = generateRoomKey(); const writeToken = generateWriteToken(); const roomId = generateRoomId(); this.#writeToken = writeToken; this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken); - this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken); + this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl); this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey); - this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey); + this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey, undefined, webUrl); const parsed = parseCollabLink(this.#link); if ("error" in parsed) throw new Error(parsed.error); const key = await importRoomKey(rawKey); diff --git a/packages/coding-agent/src/collab/protocol.ts b/packages/coding-agent/src/collab/protocol.ts index 7b6513511..26cace5a2 100644 --- a/packages/coding-agent/src/collab/protocol.ts +++ b/packages/coding-agent/src/collab/protocol.ts @@ -116,6 +116,10 @@ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/; const B64URL_RE = /^[A-Za-z0-9_-]+$/; const LOCAL_HOSTNAMES: Record = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true }; +function isLocalHostname(hostname: string): boolean { + return LOCAL_HOSTNAMES[hostname] === true; +} + export function generateRoomId(): string { const bytes = new Uint8Array(ROOM_ID_BYTES); crypto.getRandomValues(bytes); @@ -143,7 +147,7 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s default: return { error: `Unsupported relay URL scheme: ${url.protocol}` }; } - if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) { + if (scheme === "ws:" && !isLocalHostname(url.hostname)) { return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" }; } const port = url.port ? `:${url.port}` : ""; @@ -178,24 +182,48 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr return `${compact}/r/${roomId}.${keyText}`; } +function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string { + const explicitWebUrl = webUrl?.trim(); + if (!explicitWebUrl) { + const normalized = normalizeRelayOrigin(relayUrl); + if ("error" in normalized) throw new Error(normalized.error); + return normalized.origin.startsWith("wss://") + ? `https://${normalized.origin.slice("wss://".length)}` + : `http://${normalized.origin.slice("ws://".length)}`; + } + + let url: URL; + try { + url = new URL(explicitWebUrl); + } catch { + throw new Error("collab.webUrl must start with http:// or https://"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("collab.webUrl must start with http:// or https://"); + } + if (url.protocol === "http:" && !isLocalHostname(url.hostname)) { + throw new Error("collab.webUrl must use https:// unless it targets localhost"); + } + if (url.search || url.hash) { + throw new Error("collab.webUrl must not include a query string or fragment"); + } + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; +} + /** - * Render the browser deep link: `http(s):///#`. The - * relay serves the web client at `/`, and the whole collab link (including the - * room key) rides in the fragment, so it never appears in any HTTP request. - * Terminals auto-link the https form, making it click-to-join. + * Render the browser deep link. The browser UI may be hosted separately from + * the relay; the fragment always carries the relay-specific collab link, so + * room secrets stay out of HTTP path and query bytes. */ export function formatCollabWebLink( relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array, + webUrl?: string, ): string { - const normalized = normalizeRelayOrigin(relayUrl); - if ("error" in normalized) throw new Error(normalized.error); - const httpOrigin = normalized.origin.startsWith("wss://") - ? `https://${normalized.origin.slice("wss://".length)}` - : `http://${normalized.origin.slice("ws://".length)}`; - return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; + return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`; } export function parseCollabLink(link: string): ParsedCollabLink | { error: string } { @@ -213,15 +241,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin } catch { return { error: `Invalid collab link: ${link}` }; } + if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { + const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const parsed = parseCollabLink(inner); + if (!("error" in parsed)) return parsed; + } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { - // Web deep link: `http(s):///#` — the fragment holds - // the whole link, so recurse on it. The recursion terminates because - // the inner text is a strict suffix of the input. + // Non-http(s) deep links may also carry a complete collab link in the + // fragment. http(s) links are handled once above so invalid fragments + // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; - if (inner) return parseCollabLink(inner); + if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1]!; diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index bb4de047f..b6989ab34 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1519,6 +1519,18 @@ export const SETTINGS_SCHEMA = { }, }, + "collab.webUrl": { + type: "string", + default: "", + ui: { + tab: "interaction", + group: "Collab", + label: "Web UI URL", + description: + "Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only", + }, + }, + "collab.displayName": { type: "string", default: "", diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 2a2f1aeab..6d34b8848 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug"; -import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui"; import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; @@ -30,6 +30,7 @@ import type { AgentSession, FreshSessionResult } from "../session/agent-session" import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; import { urlHyperlinkAlways } from "../tui"; import { getChangelogPath, parseChangelog } from "../utils/changelog"; +import { CollabQrCodeComponent, renderCollabQrCode } from "./helpers/collab-qrcode"; import { buildContextReportText } from "./helpers/context-report"; import { formatDuration } from "./helpers/format"; import { createMarketplaceManager } from "./helpers/marketplace-manager"; @@ -99,6 +100,26 @@ function collabLinkHint(host: CollabHost, heading: string, view = false): string ].join("\n"); } +async function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): Promise { + try { + const qrText = await renderCollabQrCode(webLink); + ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink, qrText)]); + } catch (err) { + ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`); + } +} + +async function showCollabLink( + ctx: InteractiveModeContext, + host: CollabHost, + heading: string, + view = false, + options?: { forceQr?: boolean }, +): Promise { + ctx.showStatus(collabLinkHint(host, heading, view), { dim: false }); + if (options?.forceQr) await showCollabQrCode(ctx, view ? host.webViewLink : host.webLink); +} + function formatFreshSessionResult(result: FreshSessionResult): string { const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states"; return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`; @@ -578,19 +599,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "collab", description: "Share this session live via a relay", - inlineHint: "[start|view|stop|status] [relayUrl]", + inlineHint: "[start|view|stop|status|qrcode|qrcode-view] [relayUrl]", subcommands: [ { name: "view", description: "Share a read-only link (guests can watch, not prompt)" }, { name: "status", description: "Show link + participants" }, { name: "stop", description: "Stop sharing" }, + { name: "qrcode", description: "Print a QR code for a full-control browser link" }, + { name: "qrcode-view", description: "Print a QR code for a read-only browser link" }, ], allowArgs: true, handleTui: async (command, runtime) => { const ctx = runtime.ctx; ctx.editor.setText(""); const args = command.args.trim(); - const [first = ""] = args.split(/\s+/, 1); - if (first === "stop") { + const { verb, rest } = parseSubcommand(args); + if (verb === "stop") { if (!ctx.collabHost) { ctx.showStatus("Not hosting a collab session"); return; @@ -599,7 +622,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ctx.showStatus("Collab stopped"); return; } - if (first === "status") { + if (verb === "status") { if (ctx.collabHost) { const names = ctx.collabHost.participants.map(p => p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name, @@ -620,15 +643,27 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ctx.showError("Already in a collab session as a guest (/leave first)"); return; } - const view = first === "view"; + const knownStartVerb = + verb === "start" || verb === "view" || verb === "qrcode" || verb === "qrcode-view" || verb === "qr-view"; + const view = verb === "view" || verb === "qrcode-view" || verb === "qr-view"; + const forceQr = verb === "qrcode" || verb === "qrcode-view" || verb === "qr-view"; if (ctx.collabHost) { - ctx.showStatus( - collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view), - { dim: false }, + await showCollabLink( + ctx, + ctx.collabHost, + forceQr + ? view + ? "Read-only collab QR code" + : "Collab QR code" + : view + ? "Read-only collab link" + : "Collab session active", + view, + { forceQr }, ); return; } - const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args; + const explicitUrl = knownStartVerb ? rest : args; const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || ""; if (!relayInput) { ctx.showError( @@ -638,15 +673,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ } // Scheme-less relay args default to wss (ws:// must be spelled out for localhost). const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`; + const webUrl = ctx.settings.get("collab.webUrl") || ""; const host = new CollabHost(ctx); try { - await host.start(relayUrl); + await host.start(relayUrl, webUrl); } catch (err) { ctx.showError(`Failed to start collab session: ${errorMessage(err)}`); return; } ctx.collabHost = host; - ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false }); + await showCollabLink(ctx, host, "Collab session started!", view, { forceQr }); }, }, { diff --git a/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts b/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts new file mode 100644 index 000000000..8e255f56f --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/collab-qrcode.ts @@ -0,0 +1,29 @@ +import { type Component, visibleWidth } from "@oh-my-pi/pi-tui"; +import * as QRCode from "qrcode"; +import { theme } from "../../modes/theme/theme"; + +export async function renderCollabQrCode(url: string): Promise { + return QRCode.toString(url, { type: "terminal", small: true, errorCorrectionLevel: "M" }); +} + +export class CollabQrCodeComponent implements Component { + readonly url: string; + readonly #lines: readonly string[]; + readonly #minWidth: number; + + constructor(url: string, qrText: string) { + this.url = url; + const lines = qrText.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + this.#lines = lines; + this.#minWidth = 1 + lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + } + + render(width: number): readonly string[] { + if (width < this.#minWidth) { + const warning = `QR code hidden: terminal width ${width}; need ${this.#minWidth}. Use the browser URL above.`; + return [` ${typeof theme === "undefined" ? warning : theme.fg("warning", warning)}`]; + } + return this.#lines.map(line => ` ${line}`); + } +} diff --git a/packages/coding-agent/test/collab/crypto.test.ts b/packages/coding-agent/test/collab/crypto.test.ts index 68f5bd181..9854ba566 100644 --- a/packages/coding-agent/test/collab/crypto.test.ts +++ b/packages/coding-agent/test/collab/crypto.test.ts @@ -123,6 +123,47 @@ describe("collab link format", () => { } }); + it("wraps custom relay links in an explicit web UI origin", () => { + const webLink = formatCollabWebLink( + "wss://relay.example.com:8443", + roomId, + key, + undefined, + "https://web.example", + ); + expect(webLink.startsWith("https://web.example/#relay.example.com:8443/r/")).toBe(true); + const parsed = parseCollabLink(webLink); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`); + }); + + it("parses non-local http web UI wrappers by their relay fragment", () => { + const keyText = Buffer.from(key).toString("base64url"); + const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${roomId}.${keyText}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${roomId}`); + }); + + it("parses split web UI wrappers with full relay URLs in the fragment", () => { + const keyText = Buffer.from(key).toString("base64url"); + const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${roomId}.${keyText}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`); + }); + + it("falls through invalid http wrapper fragments without reparsing them", () => { + expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({ + error: "Collab link must contain a /r/ path", + }); + }); + + it("prefers browser wrapper fragments over relay-like web paths", () => { + const inner = formatCollabLink("wss://relay.example.com", roomId, key); + const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${roomId}`); + }); + it("parses the scheme-less display form of web deep links", () => { const parsed = parseCollabLink(`my.omp.sh/#${formatCollabLink(DEFAULT_RELAY_URL, roomId, key)}`); if ("error" in parsed) throw new Error(parsed.error); @@ -164,6 +205,46 @@ describe("collab link format", () => { expect(url.search).toBe(""); expect(url.hash).toBe(`#${roomId}.${Buffer.from(key).toString("base64url")}`); }); + + it("normalizes explicit web UI roots, paths, trailing slashes, and ports", () => { + const rootLink = formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, " https://web.example/ "); + expect(rootLink.startsWith("https://web.example/#")).toBe(true); + + const pathLink = formatCollabWebLink( + DEFAULT_RELAY_URL, + roomId, + key, + undefined, + "https://web.example:8443/collab///", + ); + expect(pathLink.startsWith("https://web.example:8443/collab/#")).toBe(true); + + const localHttpLink = formatCollabWebLink( + DEFAULT_RELAY_URL, + roomId, + key, + undefined, + "http://localhost:5173/app/", + ); + expect(localHttpLink.startsWith("http://localhost:5173/app/#")).toBe(true); + }); + + it("rejects web UI URLs without an http or https protocol", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "ftp://web.example")).toThrow( + "collab.webUrl must start with http:// or https://", + ); + }); + + it("rejects non-local plain-http web UI URLs", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "http://web.example")).toThrow( + "collab.webUrl must use https:// unless it targets localhost", + ); + }); + it("rejects web UI URLs with query strings or fragments", () => { + expect(() => formatCollabWebLink(DEFAULT_RELAY_URL, roomId, key, undefined, "https://web.example/?x=1")).toThrow( + "collab.webUrl must not include a query string or fragment", + ); + }); }); describe("collab wire envelope", () => { diff --git a/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts b/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts new file mode 100644 index 000000000..2ae87f621 --- /dev/null +++ b/packages/coding-agent/test/slash-commands/collab-qrcode.test.ts @@ -0,0 +1,167 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { + type BuiltinSlashCommandRuntime, + executeBuiltinSlashCommand, +} from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; +import { CollabQrCodeComponent } from "@oh-my-pi/pi-coding-agent/slash-commands/helpers/collab-qrcode"; +import { Spacer } from "@oh-my-pi/pi-tui"; + +beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + await initTheme(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(() => { + resetSettingsForTest(); +}); + +function fakeHost(options?: { + webLink?: string; + webViewLink?: string; +}): NonNullable { + return { + link: "relay.example.com/r/full-control", + viewLink: "relay.example.com/r/read-only", + webLink: options?.webLink ?? "https://my.omp.sh/#full-control", + webViewLink: options?.webViewLink ?? "https://my.omp.sh/#read-only", + participants: [{ name: "host", role: "host" }], + } as unknown as NonNullable; +} + +function createRuntimeHarness(options?: { collabHost?: NonNullable }) { + const setText = vi.fn(); + const showStatus = vi.fn(); + const showError = vi.fn(); + const present = vi.fn(); + const settingsGet = vi.fn((key: string) => { + if (key === "collab.relayUrl") return "wss://relay.example.com"; + if (key === "collab.webUrl") return ""; + return ""; + }); + const ctx = { + editor: { setText }, + showStatus, + showError, + present, + settings: { get: settingsGet }, + collabHost: options?.collabHost, + } as unknown as InteractiveModeContext; + return { + ctx, + setText, + showStatus, + showError, + present, + runtime: { ctx } as BuiltinSlashCommandRuntime, + }; +} + +function mockStartedHostLinks() { + return vi.spyOn(CollabHost.prototype, "start").mockImplementation(function (this: CollabHost): Promise { + Object.defineProperties(this, { + link: { value: "relay.example.com/r/full-control", configurable: true }, + viewLink: { value: "relay.example.com/r/read-only", configurable: true }, + webLink: { value: "https://my.omp.sh/#started-full", configurable: true }, + webViewLink: { value: "https://my.omp.sh/#started-view", configurable: true }, + participants: { value: [{ name: "host", role: "host" as const }], configurable: true }, + }); + return Promise.resolve(); + }); +} + +describe("/collab qrcode slash command", () => { + it("starts hosting and prints a one-shot full-control QR", async () => { + const startSpy = mockStartedHostLinks(); + const harness = createRuntimeHarness(); + + const handled = await executeBuiltinSlashCommand("/collab qrcode", harness.runtime); + + expect(handled).toBe(true); + expect(harness.setText).toHaveBeenCalledWith(""); + expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", ""); + expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#started-full"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe("https://my.omp.sh/#started-full"); + expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/); + }); + + it("starts hosting and prints a one-shot read-only QR", async () => { + const startSpy = mockStartedHostLinks(); + const harness = createRuntimeHarness(); + + const handled = await executeBuiltinSlashCommand("/collab qrcode-view", harness.runtime); + + expect(handled).toBe(true); + expect(startSpy).toHaveBeenCalledWith("wss://relay.example.com", ""); + expect(harness.ctx.collabHost).toBeInstanceOf(CollabHost); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#started-view"); + expect(statusText).not.toContain("my.omp.sh/#started-full"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe("https://my.omp.sh/#started-view"); + }); + + it("prints the active full-control browser QR when hosting", async () => { + const harness = createRuntimeHarness({ collabHost: fakeHost() }); + + const handled = await executeBuiltinSlashCommand("/collab qrcode", harness.runtime); + + expect(handled).toBe(true); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain("my.omp.sh/#full-control"); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.render(120).join("\n")).toMatch(/\x1b\[(?:47|40)m/); + }); + + it("prints a one-shot read-only browser QR when hosting", async () => { + const webLink = "https://my.omp.sh/#full-control"; + const webViewLink = "https://my.omp.sh/#read-only"; + const harness = createRuntimeHarness({ collabHost: fakeHost({ webLink, webViewLink }) }); + + const handled = await executeBuiltinSlashCommand("/collab qrcode-view", harness.runtime); + + expect(handled).toBe(true); + const statusText = harness.showStatus.mock.calls[0]?.[0] as string; + expect(statusText).toContain(webViewLink); + expect(statusText).not.toContain(webLink); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[0]).toBeInstanceOf(Spacer); + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + const component = presented[1] as CollabQrCodeComponent; + expect(component.url).toBe(webViewLink); + expect(component.render(10).join("\n")).toContain("QR code hidden"); + }); + + it("accepts qr-view as an alias for qrcode-view", async () => { + const webLink = "https://my.omp.sh/#full-control"; + const webViewLink = "https://my.omp.sh/#read-only"; + const harness = createRuntimeHarness({ collabHost: fakeHost({ webLink, webViewLink }) }); + + const handled = await executeBuiltinSlashCommand("/collab qr-view", harness.runtime); + + expect(handled).toBe(true); + const presented = harness.present.mock.calls[0]?.[0] as readonly unknown[]; + expect(presented[1]).toBeInstanceOf(CollabQrCodeComponent); + expect((presented[1] as CollabQrCodeComponent).url).toBe(webViewLink); + }); +}); diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index 55fa798e6..4bff33a5f 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -15,6 +15,10 @@ - Removed rendering support for the `render_mermaid` tool from the web tool registry +### Added + +- Added support for collab browser wrapper links whose web UI host differs from the relay host, so the connect screen joins the relay encoded in the URL fragment. + ## [15.13.3] - 2026-06-15 ### Fixed diff --git a/packages/collab-web/src/lib/link.ts b/packages/collab-web/src/lib/link.ts index 2c3ccd447..f90697689 100644 --- a/packages/collab-web/src/lib/link.ts +++ b/packages/collab-web/src/lib/link.ts @@ -157,15 +157,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin } catch { return { error: `Invalid collab link: ${link}` }; } + if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) { + const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; + const parsed = parseCollabLink(inner); + if (!("error" in parsed)) return parsed; + } const normalized = normalizeRelayOrigin(url.origin); if ("error" in normalized) return normalized; const match = ROOM_PATH_RE.exec(url.pathname); if (!match) { - // Web deep link: `http(s):///#` — the fragment holds - // the whole link, so recurse on it. The recursion terminates because - // the inner text is a strict suffix of the input. + // Non-http(s) deep links may also carry a complete collab link in the + // fragment. http(s) links are handled once above so invalid fragments + // fall through to direct relay validation instead of double-recursing. const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash; - if (inner) return parseCollabLink(inner); + if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner); return { error: "Collab link must contain a /r/ path" }; } const roomId = match[1] as string; diff --git a/packages/collab-web/test/link.test.ts b/packages/collab-web/test/link.test.ts index cfe4c9fa0..ece05c2f9 100644 --- a/packages/collab-web/test/link.test.ts +++ b/packages/collab-web/test/link.test.ts @@ -82,6 +82,31 @@ describe("collab link parsing", () => { expect(local.wsUrl).toBe(`ws://localhost:7466/r/${ROOM}`); }); + it("parses custom web UI wrappers by their relay fragment", () => { + const parsed = parseCollabLink(`http://web.example/collab/#relay.example.com:8443/r/${ROOM}.${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com:8443/r/${ROOM}`); + }); + + it("parses split web UI wrappers with full relay URLs in the fragment", () => { + const parsed = parseCollabLink(`https://web.example/collab/#wss://relay.example.com/r/${ROOM}.${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + }); + + it("falls through invalid http wrapper fragments without reparsing them", () => { + expect(parseCollabLink("https://web.example/#not-a-collab-link")).toEqual({ + error: "Collab link must contain a /r/ path", + }); + }); + + it("prefers browser wrapper fragments over relay-like web paths", () => { + const inner = formatCollabLink("wss://relay.example.com", ROOM, KEY); + const parsed = parseCollabLink(`https://web.example/r/abcdefghij#${inner}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + }); + it("parses dot-joined web deep links (https:///#.)", () => { const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}.${KEY_TEXT}`); if ("error" in parsed) throw new Error(parsed.error); @@ -89,6 +114,13 @@ describe("collab link parsing", () => { expect(parsed.key).toEqual(KEY); }); + it("parses legacy https direct relay links with key-only fragments", () => { + const parsed = parseCollabLink(`https://relay.example.com/r/${ROOM}#${KEY_TEXT}`); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.wsUrl).toBe(`wss://relay.example.com/r/${ROOM}`); + expect(parsed.key).toEqual(KEY); + }); + it("accepts %23-mangled legacy deep links (macOS Foundation re-encoding)", () => { const parsed = parseCollabLink(`https://my.omp.sh/#${ROOM}%23${KEY_TEXT}`); if ("error" in parsed) throw new Error(parsed.error);