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.
This commit is contained in:
Bin Jin
2026-06-19 01:21:21 +02:00
committed by can1357
parent f743ddc83d
commit e91d2cc776
14 changed files with 465 additions and 41 deletions
+23 -6
View File
@@ -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 <relay>` | 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 <link>` | 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 <link>` and `omp join "<link>"`:
```
https://host[:port]/#<link> → browser deep link (printed by /collab; /join accepts it too)
<roomId>.<key> → default relay (my.omp.sh)
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
ws://localhost:7475/r/<roomId>.<key> → plain ws, allowed for localhost only
<roomId>.<key> → default relay (wss://my.omp.sh)
<roomId>#<key> → legacy bare form
host[:port]/r/<roomId>.<key> → custom relay, wss:// inferred
host[:port]/r/<roomId>#<key> → legacy direct relay form
https://host[:port]/r/<roomId>.<key> → direct relay URL, normalized to wss://
wss://host[:port]/r/<roomId>.<key> → direct websocket relay URL
ws://localhost:7475/r/<roomId>.<key> → direct plain ws, localhost only
https://host[:port]/#<link> → browser deep link when web UI and relay share a host
https://web-host[:port][/<path>]/#<relay-link> → browser UI wrapper with relay link in the fragment
https://web.example/collab/#relay.example.com/r/<roomId>.<key> → web UI and relay on different hosts
```
The trailing `.<key>` part is the room secret, base64url-encoded, in one of two strengths:
`<link>` / `<relay-link>` 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/<roomId>.<key>` open the web UI at `web.example` while joining `wss://relay.example.com/r/<roomId>`. If the fragment is not a complete collab link, parsing falls back to the legacy direct relay form, so `https://relay.example.com/r/<roomId>#<key>` still means relay `relay.example.com`.
The trailing `.<key>` or `#<key>` 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://<relay>/#<link>` 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 `<base>/<id>#<key>`) |
| `share.redactSecrets` | `true` | Run the secret obfuscator over `/share` snapshots before upload |
+2
View File
@@ -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",
+4
View File
@@ -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
+2
View File
@@ -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:"
+4 -4
View File
@@ -133,7 +133,7 @@ export class CollabHost {
return this.#link;
}
/** Browser deep link (`https://<relay>/#<link>`) — 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<void> {
async start(relayUrl: string, webUrl = ""): Promise<void> {
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);
+48 -15
View File
@@ -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<string, true> = { 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)://<relay-host>/#<collab-link>`. 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)://<relay>/#<collab-link>` — 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/<roomId> path" };
}
const roomId = match[1]!;
@@ -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: "",
@@ -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<void> {
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<void> {
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<SlashCommandSpec> = [
{
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<SlashCommandSpec> = [
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<SlashCommandSpec> = [
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<SlashCommandSpec> = [
}
// 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 });
},
},
{
@@ -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<string> {
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}`);
}
}
@@ -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/<roomId> 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", () => {
@@ -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<InteractiveModeContext["collabHost"]> {
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<InteractiveModeContext["collabHost"]>;
}
function createRuntimeHarness(options?: { collabHost?: NonNullable<InteractiveModeContext["collabHost"]> }) {
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<void> {
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);
});
});
+4
View File
@@ -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
+9 -4
View File
@@ -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)://<relay>/#<collab-link>` — 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/<roomId> path" };
}
const roomId = match[1] as string;
+32
View File
@@ -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/<roomId> 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://<relay>/#<roomId>.<key>)", () => {
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);