cdd6e558e9
# Conflicts: # packages/coding-agent/src/collab/protocol.ts
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
/**
|
|
* Collab live-session wire protocol.
|
|
*
|
|
* Hub topology: the host is authoritative, guests never peer. All session
|
|
* payloads (`CollabFrame`) travel AES-256-GCM sealed; the relay only sees the
|
|
* plaintext envelope (`[4B uint32 BE peerId][sealed payload]`) plus TEXT JSON
|
|
* control messages that carry no session data.
|
|
*/
|
|
|
|
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
|
|
import type {
|
|
BusChannel,
|
|
CollabUiRequest,
|
|
GuestFrame,
|
|
ParsedCollabLink,
|
|
Participant,
|
|
SessionState,
|
|
AgentSnapshot as WireAgentSnapshot,
|
|
} from "@oh-my-pi/pi-wire";
|
|
import {
|
|
DEFAULT_RELAY_URL,
|
|
ENVELOPE_HEADER_LENGTH,
|
|
ROOM_ID_BYTES,
|
|
ROOM_KEY_BYTES,
|
|
WRITE_TOKEN_BYTES,
|
|
} from "@oh-my-pi/pi-wire";
|
|
import type { ContextUsage } from "../extensibility/extensions/types";
|
|
import type { AgentSessionEvent } from "../session/agent-session";
|
|
import type { SessionEntry, SessionHeader } from "../session/session-entries";
|
|
|
|
export type {
|
|
CollabPromptDetails,
|
|
CollabUiRequest,
|
|
CollabUiRequestDraft,
|
|
CollabUiResponseValue,
|
|
CollabUiSelectItem,
|
|
ParsedCollabLink,
|
|
RelayControlMessage,
|
|
RelayControlToGuest,
|
|
RelayControlToHost,
|
|
} from "@oh-my-pi/pi-wire";
|
|
export { COLLAB_PROMPT_MESSAGE_TYPE, COLLAB_PROTO } from "@oh-my-pi/pi-wire";
|
|
export { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES };
|
|
|
|
export type CollabParticipant = Participant;
|
|
export type AgentSnapshot = WireAgentSnapshot;
|
|
|
|
/** Debounced footer snapshot broadcast by the host. */
|
|
export type CollabSessionState = SessionState & {
|
|
/**
|
|
* Host model (full catalog object). Guests apply it to their replica
|
|
* agent state so model display and context-window math are native.
|
|
*/
|
|
model?: Model;
|
|
/** Host status-line context numbers (guest system prompt/tools differ, so local estimates drift). */
|
|
contextUsage?: ContextUsage;
|
|
};
|
|
|
|
/**
|
|
* Encrypted payload frames (inside AES-GCM, JSON). The wire package pins the
|
|
* JSON skeleton (`WireFrame`); host-side frames carry the rich session types
|
|
* that serialize into those shapes.
|
|
*/
|
|
export type CollabFrame =
|
|
// guest -> host (hello/abort/agent-cmd/fetch-transcript/ui-response are taken verbatim from the wire grammar)
|
|
| Exclude<GuestFrame, { t: "prompt" }>
|
|
| { t: "prompt"; text: string; images?: ImageContent[] }
|
|
// host -> guest
|
|
| {
|
|
t: "welcome";
|
|
proto: number;
|
|
header: SessionHeader;
|
|
state: CollabSessionState;
|
|
agents: AgentSnapshot[];
|
|
/**
|
|
* Total number of `SessionEntry` items the host will deliver in the
|
|
* `snapshot-chunk` frames that follow. The guest stays in the
|
|
* snapshot-loading phase until it has accumulated that many entries
|
|
* (or a chunk arrives with `final: true`).
|
|
*/
|
|
entryCount: number;
|
|
/** True when this peer joined through a read-only (view) link. */
|
|
readOnly?: boolean;
|
|
}
|
|
/**
|
|
* Targeted snapshot fragment delivered after `welcome`. Splits a large
|
|
* transcript across many small frames so the guest's per-chunk progress
|
|
* timeout resets each time the relay delivers another batch; without
|
|
* chunking, a multi-MB session has to fit one giant frame inside the
|
|
* 30 s first-welcome budget. The last chunk carries `final: true` so the
|
|
* guest can finalize the replica session.
|
|
*/
|
|
| { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean }
|
|
| { t: "entry"; entry: SessionEntry }
|
|
| { t: "event"; event: AgentSessionEvent }
|
|
| { t: "state"; state: CollabSessionState }
|
|
/** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */
|
|
| { t: "bus"; channel: BusChannel; data: unknown }
|
|
/** Full agent-registry snapshot (debounced on registry change). */
|
|
| { t: "agents"; agents: AgentSnapshot[] }
|
|
| { t: "ui-request"; request: CollabUiRequest }
|
|
| { t: "ui-request-end"; reqId: number }
|
|
/** Targeted reply to fetch-transcript; `error` marks a terminal read failure that guests must surface without hot retrying. */
|
|
| { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
|
|
| { t: "bye"; reason: string }
|
|
| { t: "error"; message: string };
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Wire envelope: [4B uint32 BE peerId][sealed payload]
|
|
// Host→relay: peerId 0 broadcasts to all guests; peerId N targets guest N.
|
|
// Guest→relay: always 0; the relay rewrites it to the sender's id.
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
export function packEnvelope(peerId: number, sealed: Uint8Array): Uint8Array {
|
|
const out = new Uint8Array(ENVELOPE_HEADER_LENGTH + sealed.byteLength);
|
|
new DataView(out.buffer).setUint32(0, peerId, false);
|
|
out.set(sealed, ENVELOPE_HEADER_LENGTH);
|
|
return out;
|
|
}
|
|
|
|
export function unpackEnvelope(data: Uint8Array): { peerId: number; payload: Uint8Array } | null {
|
|
if (data.byteLength < ENVELOPE_HEADER_LENGTH) return null;
|
|
const peerId = new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).getUint32(0, false);
|
|
return { peerId, payload: data.subarray(ENVELOPE_HEADER_LENGTH) };
|
|
}
|
|
|
|
/** Rewrite the peerId in place without copying the payload. */
|
|
export function rewriteEnvelopePeer(data: Uint8Array, peerId: number): void {
|
|
new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).setUint32(0, peerId, false);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Link format: wss://<host[:port]>/r/<roomId>.<base64url-32-byte-key>
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})(?:\.([A-Za-z0-9_-]+))?$/;
|
|
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);
|
|
return Buffer.from(bytes).toString("base64url");
|
|
}
|
|
|
|
/** Normalize a relay base URL (ws/wss/http/https) into a ws/wss origin, or an error. */
|
|
function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: string } {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(relayUrl);
|
|
} catch {
|
|
return { error: `Invalid relay URL: ${relayUrl}` };
|
|
}
|
|
let scheme: string;
|
|
switch (url.protocol) {
|
|
case "wss:":
|
|
case "https:":
|
|
scheme = "wss:";
|
|
break;
|
|
case "ws:":
|
|
case "http:":
|
|
scheme = "ws:";
|
|
break;
|
|
default:
|
|
return { error: `Unsupported relay URL scheme: ${url.protocol}` };
|
|
}
|
|
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}` : "";
|
|
return { origin: `${scheme}//${url.hostname}${port}` };
|
|
}
|
|
|
|
/**
|
|
* Render the shareable link. Compact forms: the default relay collapses to
|
|
* `<roomId>.<key>`, other wss relays drop the scheme (`host[:port]/r/…`);
|
|
* only localhost ws:// links keep their full URL so parsing cannot
|
|
* mis-infer wss.
|
|
*
|
|
* The room secret is dot-joined (`<roomId>.<key>`) rather than `#`-joined:
|
|
* RFC 3986 forbids a raw `#` inside a fragment, so strict URL stacks (macOS
|
|
* Foundation behind terminal click-to-open) percent-encode a second `#` to
|
|
* `%23` and break the link. Parsers still accept the legacy `#` form and the
|
|
* mangled `%23` form.
|
|
*
|
|
* Full links append the write token to the key
|
|
* (`base64url(key ∥ writeToken)`); read-only (view) links carry the bare
|
|
* 32-byte key, which is also the pre-token link format.
|
|
*/
|
|
export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array): string {
|
|
const normalized = normalizeRelayOrigin(relayUrl);
|
|
if ("error" in normalized) throw new Error(normalized.error);
|
|
const secret = writeToken ? Buffer.concat([key, writeToken]) : Buffer.from(key);
|
|
const keyText = secret.toString("base64url");
|
|
if (normalized.origin === DEFAULT_RELAY_URL) return `${roomId}.${keyText}`;
|
|
const compact = normalized.origin.startsWith("wss://")
|
|
? normalized.origin.slice("wss://".length)
|
|
: normalized.origin;
|
|
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. 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 {
|
|
return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
|
|
}
|
|
|
|
export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
|
|
// Lenient input: terminals that open OSC 8 links through strict URL stacks
|
|
// (macOS Foundation) percent-encode the legacy second `#` to `%23`.
|
|
let text = link.trim().replace(/%23/gi, "#");
|
|
// Bare `<roomId>.<key>` (legacy `<roomId>#<key>`) → default relay.
|
|
const bare = BARE_LINK_RE.exec(text);
|
|
if (bare) text = `${DEFAULT_RELAY_URL}/r/${bare[1]}.${bare[2]}`;
|
|
// Scheme-less `host[:port]/r/…` → wss.
|
|
else if (!text.includes("://")) text = `wss://${text}`;
|
|
let url: URL;
|
|
try {
|
|
url = new URL(text);
|
|
} 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) {
|
|
// 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 && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner);
|
|
return { error: "Collab link must contain a /r/<roomId> path" };
|
|
}
|
|
const roomId = match[1]!;
|
|
// Key rides dot-joined in the path (`/r/<roomId>.<key>`); legacy links
|
|
// carry it in the fragment (`/r/<roomId>#<key>`).
|
|
const fragment = match[2] ?? (url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
|
if (!fragment) {
|
|
return { error: "Collab link is missing the <key> part" };
|
|
}
|
|
const secret = B64URL_RE.test(fragment) ? new Uint8Array(Buffer.from(fragment, "base64url")) : null;
|
|
if (!secret || (secret.byteLength !== ROOM_KEY_BYTES && secret.byteLength !== ROOM_KEY_BYTES + WRITE_TOKEN_BYTES)) {
|
|
return { error: "Collab link key must be 32 (view) or 48 (full) base64url bytes" };
|
|
}
|
|
const key = secret.subarray(0, ROOM_KEY_BYTES);
|
|
const writeToken = secret.byteLength > ROOM_KEY_BYTES ? secret.subarray(ROOM_KEY_BYTES) : undefined;
|
|
return { wsUrl: `${normalized.origin}/r/${roomId}`, roomId, key, writeToken };
|
|
}
|