From 6722b1d3e5642c122d61cd8ce987d41d1499a628 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:41:03 -0700 Subject: [PATCH 01/11] feat: implement browser preview workspace and resolve authenticated-only selection gap --- THIRD_PARTY_NOTICES.md | 2 +- apps/web/src/components/SessionScreen.tsx | 23 +- apps/web/src/features/panes/activity-log.ts | 66 ++ apps/web/src/features/panes/live-inspector.ts | 2 + .../web/src/features/panes/live-projection.ts | 22 +- .../src/features/preview/PreviewWorkspace.tsx | 423 ++++++++++ .../web/src/features/preview/preview-model.ts | 168 ++++ .../src/features/preview/preview-runtime.ts | 175 ++++ .../src/features/transcript/SessionMain.tsx | 47 +- .../transcript/tool-render/tool-render.css | 15 +- .../transcript/tool-render/tools/browser.tsx | 12 +- .../features/transcript/tool-render/types.ts | 2 + apps/web/src/router.tsx | 61 +- apps/web/src/state/workspace-store.ts | 20 + apps/web/test/panes-activity.test.ts | 52 ++ apps/web/test/panes-live.test.ts | 38 + apps/web/test/preview-workspace.test.ts | 239 ++++++ apps/web/test/tool-renderers.test.tsx | 27 + apps/web/test/workspace-store.test.ts | 28 +- compat/omp-app-matrix.json | 18 +- e2e/remote-app.spec.ts | 31 +- packages/client/src/index.ts | 37 + packages/client/src/omp-client-runtime.ts | 774 ++++++++++++++++-- packages/client/src/preview.ts | 490 +++++++++++ packages/client/src/projection-cache.ts | 321 +++++++- packages/client/src/projection.ts | 391 ++++++++- packages/client/test/client-reconnect.test.ts | 110 +++ packages/client/test/preview-lease.test.ts | 83 ++ packages/client/test/projection.test.ts | 455 +++++++++- packages/fixture-server/src/engine.ts | 134 ++- .../src/fixture-command-frames.ts | 145 +++- .../fixture-server/src/fixture-sessions.ts | 2 + packages/fixture-server/test/engine.test.ts | 30 +- packages/protocol/package.json | 2 +- packages/protocol/test/distribution.test.ts | 16 +- pnpm-lock.yaml | 12 +- vendor/app-wire/manifest.json | 14 +- vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz | Bin 0 -> 44031 bytes 38 files changed, 4283 insertions(+), 204 deletions(-) create mode 100644 apps/web/src/features/preview/PreviewWorkspace.tsx create mode 100644 apps/web/src/features/preview/preview-model.ts create mode 100644 apps/web/src/features/preview/preview-runtime.ts create mode 100644 apps/web/test/preview-workspace.test.ts create mode 100644 packages/client/src/preview.ts create mode 100644 packages/client/test/preview-lease.test.ts create mode 100644 vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f15bbc3..f2a7fa2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd ## Oh My Pi -Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.9` package is packed from the public `lyc-aon/oh-my-pi` integration commit `5633bdd7e5f9062d1822eeeddb9311b2d942bf6f`, source tree `4d8794bad6fc57d86058a46dc4698fcca14263e5`; tarball SHA-256 `b3a891610e919833d16302b1893831f509d264322c3869d28f17adbbff6116f0`; golden corpus SHA-256 `50b087a3a22bb48908718b7786eff6ce618bbd6b6123c055e40c957ef47a805c`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. +Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.6.0` package is packed from the public `lyc-aon/oh-my-pi` integration commit `ae4b53b416f32b200865a32ed9baabd5a4666fa4`, source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`; tarball SHA-256 `92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c`; golden corpus SHA-256 `7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. ## Oh My Pi icon diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index e74f618..bef89d8 100644 --- a/apps/web/src/components/SessionScreen.tsx +++ b/apps/web/src/components/SessionScreen.tsx @@ -16,6 +16,7 @@ import { useReducedMotion, } from "@t4-code/ui"; import { Popover } from "@base-ui/react/popover"; +import { Link } from "@tanstack/react-router"; import { Check, PanelBottomClose, PanelBottomOpen, PanelRight, X } from "lucide-react"; import { useEffect, useState } from "react"; @@ -25,6 +26,8 @@ import { TerminalDrawer } from "../features/terminal/TerminalDrawer.tsx"; import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx"; import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts"; import { useWorkspace, workspaceStore } from "../state/store-instance.ts"; +import { useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; +import { resolveLiveSession } from "../platform/live-workspace.ts"; import { type PaneFamily, RIGHT_PANE_WIDTH, @@ -157,6 +160,15 @@ export function SessionScreen({ (state) => selectSessionView(state, session.id).terminalDrawerOpen, ); const paneDocks = useMediaQuery(RIGHT_PANE_DOCK_QUERY); + const runtimeSnapshot = useDesktopRuntimeSnapshot(); + const previewAddress = + runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, session.id); + const previewCount = + previewAddress === null + ? 0 + : (runtimeSnapshot?.projection.sessions + .get(`${previewAddress.hostId}\u0000${previewAddress.sessionId}`) + ?.previews.size ?? 0); const [panePreviewWidth, setPanePreviewWidth] = useState(null); // Transcript scroll ownership lives in TranscriptTimeline (virtualized @@ -206,7 +218,16 @@ export function SessionScreen({ - + {previewCount > 0 && ( + + Preview{previewCount === 1 ? "" : ` · ${previewCount}`} + + )} {!archived && ( )} diff --git a/apps/web/src/features/panes/activity-log.ts b/apps/web/src/features/panes/activity-log.ts index a75de44..97a6db1 100644 --- a/apps/web/src/features/panes/activity-log.ts +++ b/apps/web/src/features/panes/activity-log.ts @@ -2,6 +2,7 @@ // filter/search them, redact secrets from the raw inspector, and export. // Pure functions — the store applies them, tests drive them directly. import { isSessionEvent, type SessionEvent } from "@t4-code/protocol"; +import type { PreviewEventProjection, PreviewFreshness } from "@t4-code/client"; import { sessionEventSpec } from "../session-runtime/session-event-vocabulary.ts"; import type { ActivityEntry, ActivityFilter, ActivityKind } from "./model.ts"; @@ -83,6 +84,71 @@ export function classifySessionEvent( }; } +function previewEventAt(event: PreviewEventProjection, fallbackAt: string): string { + if ( + event.timestamp === undefined || + !Number.isFinite(event.timestamp) || + event.timestamp < 0 || + event.timestamp > 8.64e15 + ) { + return fallbackAt; + } + return new Date(event.timestamp).toISOString(); +} + + +/** + * Browser-preview activity is projection metadata, not a browser audit log. + * Keep only the already-sanitized origin/path, capture identity, timestamp, + * and a generic error outcome. Query/hash values, pixels, credentials, and + * backend error text never enter Activity or its export payload. + */ +export function classifyPreviewEvent( + event: PreviewEventProjection, + seq: number, + fallbackAt: string, + freshness?: PreviewFreshness, +): ActivityEntry { + const url = event.url === undefined ? null : `${event.url.origin}${event.url.pathname}`; + const context = freshness === "cached" || freshness === "stale" ? freshness : null; + const detailParts: string[] = []; + if (url !== null) detailParts.push(url); + if (event.type === "capture" && event.captureId !== undefined) { + detailParts.push(`Capture ${event.captureId}`); + } + if (context !== null) detailParts.push(context); + const title = + event.type === "launch" + ? "Browser preview launched" + : event.type === "navigation" + ? "Browser preview navigated" + : event.type === "capture" + ? "Browser preview captured" + : "Browser preview failed"; + return { + seq, + at: previewEventAt(event, fallbackAt), + kind: event.type === "error" ? "error" : "system", + title, + detail: event.type === "error" ? "The browser preview request did not complete." : (detailParts.join(" · ") || null), + agentId: null, + terminalId: null, + raw: { + type: `preview.${event.type}`, + previewId: event.previewId, + cursor: event.cursor, + ...(url === null ? {} : { url }), + ...(event.type === "capture" && event.captureId !== undefined + ? { captureId: event.captureId } + : {}), + ...(event.timestamp === undefined ? {} : { timestamp: event.timestamp }), + ...(context === null ? {} : { freshness: context }), + }, + unknown: false, + shellOutput: null, + }; +} + /** Append with the retention cap; order is seq order, oldest dropped first. */ export function appendActivity( entries: readonly ActivityEntry[], diff --git a/apps/web/src/features/panes/live-inspector.ts b/apps/web/src/features/panes/live-inspector.ts index 3e7142a..400ffd5 100644 --- a/apps/web/src/features/panes/live-inspector.ts +++ b/apps/web/src/features/panes/live-inspector.ts @@ -609,6 +609,8 @@ function emptyProjection(): SessionProjection { audit: [], confirmations: new Map(), results: new Map(), + previews: new Map(), + previewEvents: [], freshness: "cached", transcriptEventArrivalOrdinal: 0, contextMaintenanceEventArrivalOrdinal: 0, diff --git a/apps/web/src/features/panes/live-projection.ts b/apps/web/src/features/panes/live-projection.ts index 1b7f9ab..857e57d 100644 --- a/apps/web/src/features/panes/live-projection.ts +++ b/apps/web/src/features/panes/live-projection.ts @@ -2,9 +2,14 @@ // desktop runtime already validated and bounded) to the inspector pane // view models. Every function here is derivation only: unknown fields stay // null, unsafe paths disappear, and nothing is invented to fill a gap. -import type { AgentTranscriptProjection, ResultProjection, SessionProjection } from "@t4-code/client"; +import type { + AgentTranscriptProjection, + PreviewFreshness, + ResultProjection, + SessionProjection, +} from "@t4-code/client"; -import { classifySessionEvent } from "./activity-log.ts"; +import { classifyPreviewEvent, classifySessionEvent } from "./activity-log.ts"; import { displayStateFromWire } from "./model.ts"; import type { ActivityEntry, @@ -205,6 +210,19 @@ export function collectActivity(session: SessionProjection): KeyedActivityEntry[ entry: classifySessionEvent(frame.event, 0, ""), }); } + for (const event of session.previewEvents) { + let freshness: PreviewFreshness | undefined; + for (const preview of session.previews.values()) { + if (preview.previewId === event.previewId) { + freshness = preview.freshness; + break; + } + } + entries.push({ + key: `preview:${event.cursor.epoch}:${event.cursor.seq}`, + entry: classifyPreviewEvent(event, 0, "", freshness), + }); + } for (const frame of session.audit) { entries.push({ key: `audit:${frame.timestamp}\u0000${frame.action}\u0000${frame.actor}`, diff --git a/apps/web/src/features/preview/PreviewWorkspace.tsx b/apps/web/src/features/preview/PreviewWorkspace.tsx new file mode 100644 index 0000000..8a15175 --- /dev/null +++ b/apps/web/src/features/preview/PreviewWorkspace.tsx @@ -0,0 +1,423 @@ +import { + Button, + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@t4-code/ui"; +import type { PreviewProjection } from "@t4-code/client"; +import { ArrowLeft, ChevronLeft, ChevronRight, Crosshair, RefreshCw, RotateCcw, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import type { WorkspaceProject, WorkspaceSession } from "../../lib/workspace-data.ts"; +import { desktopRuntime, useDesktopRuntimeSnapshot } from "../../platform/desktop-runtime.ts"; +import { resolveLiveSession } from "../../platform/live-workspace.ts"; +import { useWorkspace, workspaceStore } from "../../state/store-instance.ts"; +import { selectSessionView } from "../../state/workspace-store.ts"; +import { + choosePreview, + defaultLaunchAuthority, + derivePreviewWorkspaceStatus, + displayedToNativeCoordinate, + isProjectRelativeUploadPath, + previewActionSupport, + previewHostSupport, + previewTrustLabel, + type PreviewAction, + type PreviewWorkspaceStatus, +} from "./preview-model.ts"; +import { PreviewDesktopAdapter } from "./preview-runtime.ts"; + +function previewStatusLabel(status: PreviewWorkspaceStatus): string { + return status === "cached" ? "Cached snapshot" : `${status.slice(0, 1).toUpperCase()}${status.slice(1)}`; +} + +function safePreviewError(error: unknown): string { + if (error instanceof Error && error.message === "Choose a project-relative upload path.") { + return error.message; + } + return "Preview operation failed. Please try again."; +} + + +export function PreviewWorkspace({ + session, + project, +}: { + readonly session: WorkspaceSession; + readonly project: WorkspaceProject; +}) { + const navigate = useNavigate(); + const snapshot = useDesktopRuntimeSnapshot(); + const controller = desktopRuntime(); + const selectedPreviewId = useWorkspace((state) => selectSessionView(state, session.id).previewId); + const scale = useWorkspace((state) => selectSessionView(state, session.id).previewScale); + const address = useMemo( + () => (snapshot === null ? null : resolveLiveSession(snapshot, session.id)), + [session.id, snapshot], + ); + const adapter = useMemo( + () => (controller === null || address === null ? null : new PreviewDesktopAdapter(controller, address)), + [address?.hostId, address?.sessionId, address?.targetId, controller], + ); + const sessionProjection = + snapshot === null || address === null + ? undefined + : snapshot.projection.sessions.get(`${address.hostId}\u0000${address.sessionId}`); + const previews = useMemo( + () => + [...(sessionProjection?.previews.values() ?? [])].sort((left, right) => + left.previewId.localeCompare(right.previewId), + ), + [sessionProjection], + ); + const preview = choosePreview(previews, selectedPreviewId); + const connected = + snapshot !== null && address !== null && snapshot.connections.get(address.targetId) === "connected"; + const hostSupport = + snapshot === null || address === null ? previewHostSupport(undefined) : previewHostSupport(snapshot.hosts.get(address.hostId)); + const status = derivePreviewWorkspaceStatus({ + preview, + connected, + supported: adapter !== null && hostSupport.supported, + }); + const identity = + preview === undefined || address === null + ? undefined + : { hostId: address.hostId, sessionId: address.sessionId, previewId: preview.previewId }; + const [url, setUrl] = useState(""); + const [captureUrl, setCaptureUrl] = useState(); + const [error, setError] = useState(); + const [selector, setSelector] = useState(""); + const [text, setText] = useState(""); + const [selectValue, setSelectValue] = useState(""); + const [key, setKey] = useState("Enter"); + const [uploadPath, setUploadPath] = useState(""); + const [scrollX, setScrollX] = useState("0"); + const [scrollY, setScrollY] = useState("400"); + + useEffect(() => { + if (preview !== undefined && preview.previewId !== selectedPreviewId) { + workspaceStore.getState().setSessionPreview(session.id, preview.previewId); + } + }, [preview, selectedPreviewId, session.id]); + + useEffect(() => { + setUrl(preview?.url ?? ""); + }, [preview?.previewId, preview?.url]); + + useEffect(() => () => { + if (adapter !== null) void adapter.dispose(); + }, [adapter]); + + useEffect(() => { + if (adapter === null || identity === undefined || preview?.capture === undefined) { + setCaptureUrl(undefined); + return; + } + let active = true; + void adapter + .objectUrl(identity, preview.capture) + .then((nextUrl) => { + if (active) setCaptureUrl(nextUrl); + }) + .catch(() => { + if (active) setError("Preview capture could not be loaded."); + }); + return () => { + active = false; + setCaptureUrl(undefined); + void adapter.release(identity); + }; + }, [adapter, identity?.previewId, preview?.capture?.captureId, preview?.capture?.sha256]); + + const support = (action: PreviewAction) => + previewActionSupport(preview, action, status, hostSupport.inputSupported); + const runAction = ( + action: PreviewAction, + _label: string, + operation: () => Promise, + requiresPreview = true, + ) => { + const actionSupport = + requiresPreview + ? support(action) + : adapter === null || !hostSupport.supported + ? { + supported: false, + reason: hostSupport.reason ?? "This host does not advertise browser preview support.", + } + : { supported: true }; + if (!actionSupport.supported || adapter === null) { + setError(actionSupport.reason ?? "Preview actions are unavailable."); + return; + } + setError(undefined); + void adapter + .policy(action, identity, action === "navigate" ? url.trim() : undefined) + .then((policy) => { + if (!policy.allowed) { + setError("This preview action is not allowed by the host."); + return; + } + return operation(); + }) + .catch((cause: unknown) => setError(safePreviewError(cause))); + }; + + const mutate = (action: PreviewAction, args: Readonly> = {}) => { + if (adapter === null || identity === undefined) return Promise.reject(new Error("Preview unavailable")); + return adapter.mutate(action, identity, args); + }; + + const previewConfirmation = [...(sessionProjection?.confirmations.values() ?? [])].find( + (challenge) => String(challenge.summary).startsWith("preview."), + ); + + const capture = preview?.capture; + const captureTimestamp = + capture === undefined ? undefined : new Date(capture.capturedAt).toLocaleString(); + const selectorPresent = selector.trim().length > 0; + const safeUploadPath = isProjectRelativeUploadPath(uploadPath); + const scrollDeltaX = Number(scrollX); + const scrollDeltaY = Number(scrollY); + const validScroll = Number.isFinite(scrollDeltaX) && Number.isFinite(scrollDeltaY); + const advancedActions = ["fill", "type", "select", "press", "scroll", "upload"] as const; + const showAdvanced = advancedActions.some((action) => support(action).supported); + + return ( +
+
+ +

Browser preview

+ {project.name} + + + {previewStatusLabel(status)} + +
+ +
+ {error ?? `${previewStatusLabel(status)}${captureTimestamp === undefined ? "" : `; captured ${captureTimestamp}`}`} +
+ +
+
+
+ +
+ setUrl(event.target.value)} + placeholder="https://localhost:3000" + value={url} + /> + +
+

Launch authority: OMP session-only authority. Authenticated profiles are never selected automatically.

+ {previews.length > 1 && ( + + )} +
+ +
+ {([ + ["activate", "Activate", Crosshair, {}], + ["back", "Back", ChevronLeft, {}], + ["forward", "Forward", ChevronRight, {}], + ["reload", "Reload", RefreshCw, {}], + ["capture", "Recapture", RotateCcw, {}], + ["close", "Close", X, {}], + ] as const).map(([action, label, Icon]) => { + const actionSupport = support(action); + return ( + + ); + })} +
+ +
+
+ Snapshot + {previewTrustLabel(preview)} + + + +
+ {captureTimestamp !== undefined &&

{status === "cached" ? "Cached" : "Captured"} {captureTimestamp}

} + {captureUrl !== undefined && preview !== undefined && capture !== undefined ? ( + support("click").supported ? ( + + ) : ( + {preview.title + ) + ) : ( +

+ {status === "unsupported" + ? "Browser preview is not supported by this runtime." + : status === "empty" + ? "Launch a browser preview to request its first snapshot." + : "No snapshot has been captured yet."} +

+ )} +
+
+ + {showAdvanced && preview !== undefined && ( + + )} +
+ + {error !== undefined &&

{error}

} + { + if (!open && previewConfirmation !== undefined && adapter !== null) { + void adapter + .confirm(previewConfirmation, "deny") + .catch((cause) => setError(safePreviewError(cause))); + } + }} + open={previewConfirmation !== undefined} + > + + + Confirm browser action + + {previewConfirmation === undefined ? "" : String(previewConfirmation.summary)} + + + + + + + + +
+ ); +} diff --git a/apps/web/src/features/preview/preview-model.ts b/apps/web/src/features/preview/preview-model.ts new file mode 100644 index 0000000..61cb65a --- /dev/null +++ b/apps/web/src/features/preview/preview-model.ts @@ -0,0 +1,168 @@ +import type { PreviewProjection } from "@t4-code/client"; + +export type PreviewWorkspaceStatus = + | "empty" + | "launching" + | "ready" + | "running" + | "stopped" + | "failed" + | "offline" + | "unsupported" + | "cached"; + +export type PreviewAction = + | "activate" + | "navigate" + | "back" + | "forward" + | "reload" + | "close" + | "capture" + | "click" + | "fill" + | "type" + | "press" + | "scroll" + | "select" + | "upload"; + +export interface PreviewActionSupport { + readonly supported: boolean; + readonly reason?: string; +} + +export interface PreviewPolicyDecision { + readonly allowed: boolean; + readonly reason?: string; +} + +export interface PreviewHostSupport { + readonly supported: boolean; + readonly inputSupported: boolean; + readonly reason?: string; +} + +export function previewHostSupport(host: { + readonly grantedCapabilities: readonly string[]; + readonly grantedFeatures: readonly string[]; +} | undefined): PreviewHostSupport { + if (host === undefined || !host.grantedFeatures.includes("preview.control")) { + return { supported: false, inputSupported: false, reason: "This host does not advertise browser preview control." }; + } + if (!host.grantedCapabilities.includes("preview.read")) { + return { supported: false, inputSupported: false, reason: "This host does not permit browser preview reads." }; + } + return { + supported: true, + inputSupported: host.grantedCapabilities.includes("preview.input"), + }; +} + +export function derivePreviewWorkspaceStatus(options: { + readonly preview: PreviewProjection | undefined; + readonly connected: boolean; + readonly supported: boolean; +}): PreviewWorkspaceStatus { + if (!options.supported) return "unsupported"; + if (!options.connected) return "offline"; + if (options.preview === undefined) return "empty"; + if (options.preview.freshness === "cached" || options.preview.freshness === "stale") { + return "cached"; + } + return options.preview.state ?? "ready"; +} + +export function previewActionSupport( + preview: PreviewProjection | undefined, + action: PreviewAction, + status: PreviewWorkspaceStatus, + inputSupported: boolean, +): PreviewActionSupport { + if (status === "unsupported") { + return { supported: false, reason: "This host does not advertise browser preview support." }; + } + if (status === "offline") { + return { supported: false, reason: "Preview actions are unavailable while this host is offline." }; + } + if (status === "cached") { + return { supported: false, reason: "Preview actions are unavailable until the cached session reconnects." }; + } + if (status === "empty" || preview === undefined) { + return { supported: false, reason: "Launch a preview before using this action." }; + } + if (["click", "fill", "type", "press", "scroll", "select", "upload"].includes(action) && !inputSupported) { + return { supported: false, reason: "This host does not permit browser preview input." }; + } + if (preview.availableActions?.includes(action) === true) return { supported: true }; + return { supported: false, reason: `This host does not advertise ${action} for this preview.` }; +} + +export function defaultLaunchAuthority(): "omp-session" { + return "omp-session"; +} + +export function choosePreview( + previews: readonly PreviewProjection[], + selectedPreviewId: string | null, +): PreviewProjection | undefined { + if (selectedPreviewId !== null) { + const selected = previews.find((preview) => preview.previewId === selectedPreviewId); + if (selected !== undefined) return selected; + } + return previews.find((preview) => preview.authority?.kind !== "authenticated-profile"); +} + +export function previewTrustLabel(preview: PreviewProjection | undefined): string { + if (preview?.authority === undefined) return "OMP session authority"; + if (preview.authority.kind === "authenticated-profile") { + return `${preview.authority.label} — authenticated profile (explicit opt-in)`; + } + return `${preview.authority.label} — isolated session`; +} + +export function isProjectRelativeUploadPath(path: string): boolean { + const value = path.trim(); + return ( + value.length > 0 && + !value.startsWith("/") && + !value.startsWith("\\") && + !/^[A-Za-z]:[\\/]/u.test(value) && + !value.split(/[\\/]+/u).includes("..") + ); +} + +export function displayedToNativeCoordinate( + point: { readonly x: number; readonly y: number }, + displayed: { readonly width: number; readonly height: number }, + native: { readonly width: number; readonly height: number }, +): { readonly x: number; readonly y: number } | null { + if ( + displayed.width <= 0 || + displayed.height <= 0 || + native.width <= 0 || + native.height <= 0 || + !Number.isFinite(point.x) || + !Number.isFinite(point.y) + ) { + return null; + } + return { + x: Math.max(0, Math.min(native.width - 1, Math.floor((point.x * native.width) / displayed.width))), + y: Math.max(0, Math.min(native.height - 1, Math.floor((point.y * native.height) / displayed.height))), + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function parsePreviewPolicyDecision(value: unknown): PreviewPolicyDecision { + if (!isRecord(value)) { + return { allowed: false, reason: "The host returned an invalid preview policy response." }; + } + return { + allowed: value.allowed === true, + ...(typeof value.reason === "string" ? { reason: value.reason } : {}), + }; +} diff --git a/apps/web/src/features/preview/preview-runtime.ts b/apps/web/src/features/preview/preview-runtime.ts new file mode 100644 index 0000000..eaf44a0 --- /dev/null +++ b/apps/web/src/features/preview/preview-runtime.ts @@ -0,0 +1,175 @@ +import { + PreviewCaptureResource, + PreviewLeaseManager, + type DesktopRuntimeController, + type PreviewCaptureMetadata, + type PreviewCaptureReadResult, + type PreviewIdentity, +} from "@t4-code/client"; +import { hostId, sessionId, type CommandId, type ConfirmationId, type HostId, type SessionId } from "@t4-code/protocol"; +import type { CommandIntent, CommandResult } from "@t4-code/protocol/desktop-ipc"; + +import type { LiveSessionAddress } from "../../platform/live-workspace.ts"; +import { + isProjectRelativeUploadPath, + parsePreviewPolicyDecision, + type PreviewAction, + type PreviewPolicyDecision, +} from "./preview-model.ts"; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function captureReadResult(value: unknown): PreviewCaptureReadResult { + if (!isRecord(value)) { + throw new Error("The host returned an invalid preview capture chunk."); + } + const record = value; + if ( + typeof record.previewId !== "string" || + typeof record.captureId !== "string" || + typeof record.size !== "number" || + typeof record.offset !== "number" || + typeof record.nextOffset !== "number" || + typeof record.complete !== "boolean" || + typeof record.content !== "string" + ) { + throw new Error("The host returned an invalid preview capture chunk."); + } + return { + previewId: record.previewId, + captureId: record.captureId, + size: record.size, + offset: record.offset, + nextOffset: record.nextOffset, + complete: record.complete, + content: record.content, + }; +} + +function commandError(result: CommandResult, command: string): Error { + const error = new Error(result.error?.message ?? `The host rejected ${command}.`); + Object.assign(error, { code: result.error?.code ?? "REJECTED" }); + return error; +} + +/** + * The renderer's narrow browser-preview authority boundary. It exposes only + * host/session scoped commands and keeps pixels plus cooperative leases local + * to this window; browser/profile state never crosses into T4. + */ +export class PreviewDesktopAdapter { + readonly captures: PreviewCaptureResource; + readonly leases: PreviewLeaseManager; + private readonly controller: DesktopRuntimeController; + readonly address: LiveSessionAddress; + + + constructor(controller: DesktopRuntimeController, address: LiveSessionAddress) { + this.controller = controller; + this.address = address; + this.captures = new PreviewCaptureResource({ + read: async (identity, captureId, offset) => + captureReadResult( + await this.command("preview.capture.read", identity, { captureId, offset }), + ), + }); + this.leases = new PreviewLeaseManager({ + previewLeaseAcquire: async (identity, ttlMs) => + this.leaseResponse(await this.command("preview.lease.acquire", identity, { ...(ttlMs === undefined ? {} : { ttlMs }) })), + previewLeaseRenew: async (identity, ttlMs) => + this.leaseResponse( + await this.command("preview.lease.renew", identity, { + leaseId: identity.leaseId, + ...(ttlMs === undefined ? {} : { ttlMs }), + }), + ), + previewLeaseRelease: async (identity) => + this.leaseResponse(await this.command("preview.lease.release", identity, { leaseId: identity.leaseId })), + }); + } + + async launch(url: string, authorityId = "omp-session"): Promise { + await this.command("preview.launch", undefined, { url, authorityId }); + } + + async policy( + action: PreviewAction, + identity: PreviewIdentity | undefined, + url?: string, + ): Promise { + const result = await this.command("preview.policy.check", identity, { + action, + ...(url === undefined ? {} : { url }), + }); + return parsePreviewPolicyDecision(result); + } + + async mutate( + action: PreviewAction, + identity: PreviewIdentity, + args: Readonly> = {}, + ): Promise { + if (action === "upload" && !isProjectRelativeUploadPath(String(args.path ?? ""))) { + throw new Error("Choose a project-relative upload path."); + } + await this.leases.mutate(identity, async (leaseId) => { + await this.command(`preview.${action}`, { ...identity, leaseId }, args); + }); + } + + async objectUrl(identity: PreviewIdentity, capture: PreviewCaptureMetadata): Promise { + return this.captures.objectUrl(identity, capture); + } + + async confirm( + challenge: { + readonly confirmationId: ConfirmationId; + readonly commandId: CommandId; + readonly hostId: HostId; + readonly sessionId?: SessionId; + }, + decision: "approve" | "deny", + ): Promise { + const result = await this.controller.confirm({ + targetId: this.address.targetId, + ...challenge, + decision, + }); + if (!result.accepted) throw new Error("The host rejected the preview confirmation."); + } + + async release(identity: PreviewIdentity): Promise { + this.captures.release(identity); + await this.leases.release(identity); + } + + async dispose(): Promise { + this.captures.dispose(); + await this.leases.releaseAll(); + } + + private async command( + command: string, + identity: (PreviewIdentity & { readonly leaseId?: string }) | undefined, + args: Readonly>, + ): Promise { + const result = await this.controller.command(this.address.targetId, { + hostId: hostId(this.address.hostId), + sessionId: sessionId(this.address.sessionId), + command, + args: { + ...(identity === undefined ? {} : { previewId: identity.previewId }), + ...(identity?.leaseId === undefined ? {} : { leaseId: identity.leaseId }), + ...args, + }, + } as CommandIntent); + if (!result.accepted) throw commandError(result, command); + return result.result; + } + + private leaseResponse(result: unknown): unknown { + return { ok: true, result }; + } +} diff --git a/apps/web/src/features/transcript/SessionMain.tsx b/apps/web/src/features/transcript/SessionMain.tsx index 2973aa5..08d38ee 100644 --- a/apps/web/src/features/transcript/SessionMain.tsx +++ b/apps/web/src/features/transcript/SessionMain.tsx @@ -4,11 +4,14 @@ // frames into a TranscriptProjection; rows derive from the projection; user // actions leave through typed SessionIntents. The shell's outer scroll // container stays inert — this surface owns its own virtualized scroller. +import { useNavigate } from "@tanstack/react-router"; import { Badge, cn, Tooltip, TooltipPopup, TooltipTrigger, useReducedMotion } from "@t4-code/ui"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { WorkspaceProject, WorkspaceSession } from "../../lib/workspace-data.ts"; import { workspaceStore } from "../../state/store-instance.ts"; +import { useDesktopRuntimeSnapshot } from "../../platform/desktop-runtime.ts"; +import { resolveLiveSession } from "../../platform/live-workspace.ts"; import { Composer } from "../composer/Composer.tsx"; import { getInspectorStore } from "../panes/inspector-store.ts"; import { @@ -49,6 +52,11 @@ export interface SessionMainProps { readonly nowMs: number; } +/** Stable session-scoped destination; all transcript state stays in the workspace store. */ +export function sessionPreviewDestination(sessionId: string) { + return { params: { sessionId }, to: "/sessions/$sessionId/preview" as const }; +} + export function FreshnessBadge({ session }: { readonly session: WorkspaceSession }) { if (session.freshness === "cached") { return ( @@ -339,8 +347,27 @@ export function SessionControlBanner({ export function SessionMain({ session }: SessionMainProps) { const archived = session.archivedAt !== undefined; + const navigate = useNavigate(); const { snapshot, runtime } = useSessionRuntime(session.id, session.freshness); const projection = snapshot.projection; + const desktopSnapshot = useDesktopRuntimeSnapshot(); + const liveAddress = useMemo( + () => (desktopSnapshot === null ? null : resolveLiveSession(desktopSnapshot, session.id)), + [desktopSnapshot, session.id], + ); + const previews = + desktopSnapshot === null || liveAddress === null + ? [] + : [ + ...( + desktopSnapshot.projection.sessions.get( + `${liveAddress.hostId}\u0000${liveAddress.sessionId}`, + )?.previews.values() ?? [] + ), + ]; + const previewFreshness = previews.some((preview) => preview.freshness !== "fresh") + ? "Cached" + : "Ready"; const toolHost = useMemo( () => ({ hasAgent: (agentId) => @@ -354,8 +381,11 @@ export function SessionMain({ session }: SessionMainProps) { if (view?.paneFamily !== "agents") workspace.togglePaneFamily(session.id, "agents"); workspace.setPaneOpen(session.id, true); }, + openPreview: () => { + void navigate(sessionPreviewDestination(session.id)); + }, }), - [session.id], + [navigate, session.id], ); // Leaving this session surface (switch or unmount) ends any read-aloud @@ -463,6 +493,21 @@ export function SessionMain({ session }: SessionMainProps) { Archived · read-only. Restore this session before continuing work. )} + {previews.length > 0 && ( +
+ +
+ )}
{empty ? (
diff --git a/apps/web/src/features/transcript/tool-render/tool-render.css b/apps/web/src/features/transcript/tool-render/tool-render.css index 7b3a292..1da4e18 100644 --- a/apps/web/src/features/transcript/tool-render/tool-render.css +++ b/apps/web/src/features/transcript/tool-render/tool-render.css @@ -143,7 +143,20 @@ text-decoration: underline; } +.tv-render .tv-host-link { + border: 0; + cursor: pointer; + font: inherit; + font-size: 0.625rem; +} + +.tv-render .tv-host-link:hover { + background: color-mix(in oklch, var(--tv-accent) 22%, transparent); + text-decoration: underline; +} + .tv-render .tv-agent-link:focus-visible, +.tv-render .tv-host-link:focus-visible, .tv-render .tv-expand:focus-visible, .tv-render .tv-image-button:focus-visible, .tv-render .tv-link:focus-visible { @@ -507,9 +520,9 @@ @media (max-width: 39.999rem) { .tv-render .tv-agent-link, + .tv-render .tv-host-link, .tv-render .tv-image-button, .tv-render .tv-link { - display: inline-flex; min-height: 2.75rem; align-items: center; } diff --git a/apps/web/src/features/transcript/tool-render/tools/browser.tsx b/apps/web/src/features/transcript/tool-render/tools/browser.tsx index 1541dab..ad2fdfe 100644 --- a/apps/web/src/features/transcript/tool-render/tools/browser.tsx +++ b/apps/web/src/features/transcript/tool-render/tools/browser.tsx @@ -69,7 +69,7 @@ function Summary({ args, result }: ToolRenderProps): ReactNode { ); } -function Body({ args, result }: ToolRenderProps): ReactNode { +function Body({ args, result, host }: ToolRenderProps): ReactNode { const details = detailsOf(result); const action = str(args.action) ?? details.action; const app = appOf(args); @@ -96,6 +96,16 @@ function Body({ args, result }: ToolRenderProps): ReactNode { {vpScale !== null ? `@${vpScale}x` : ""} )} + {host?.openPreview !== undefined && ( + + )} {action === "run" && code !== null && ( diff --git a/apps/web/src/features/transcript/tool-render/types.ts b/apps/web/src/features/transcript/tool-render/types.ts index 96b6096..afe95e9 100644 --- a/apps/web/src/features/transcript/tool-render/types.ts +++ b/apps/web/src/features/transcript/tool-render/types.ts @@ -44,6 +44,8 @@ export interface ToolRenderHost { hasAgent?(id: string): boolean; /** Open the sub-session/transcript view for an agent id. */ openAgent?(id: string): void; + /** Open this session's focused browser preview workspace. */ + openPreview?(): void; } export interface ToolRenderProps { diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index affd9ef..63d2f3a 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -19,12 +19,13 @@ import { useNavigate, useParams, } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { AppShell } from "./components/AppShell.tsx"; import { HomePane } from "./components/HomePane.tsx"; import { SessionScreen } from "./components/SessionScreen.tsx"; import { AgentViewScreen } from "./features/agent-view/AgentViewScreen.tsx"; +import { PreviewWorkspace } from "./features/preview/PreviewWorkspace.tsx"; import { SettingsWorkspace } from "./features/settings/index.ts"; import { LiveSettingsScreen } from "./features/settings/LiveSettingsScreen.tsx"; import { TargetsScreen } from "./features/targets/TargetsScreen.tsx"; @@ -34,6 +35,7 @@ import { type ProfilesPort, type TargetsStoreApi, } from "./features/targets/targets-store.ts"; +import type { WorkspaceProject, WorkspaceSession } from "./lib/workspace-data.ts"; import { applySessionRoutePendingGrace, createSessionRouteActivationGate, @@ -81,8 +83,17 @@ const indexRoute = createRoute({ component: HomeRoute, }); -function SessionRoute() { - const { sessionId } = useParams({ from: "/sessions/$sessionId" }); +interface SessionRouteGateProps { + readonly sessionId: string; + readonly previewRoute: boolean; + readonly children: ( + session: WorkspaceSession, + project: WorkspaceProject, + nowMs: number, + ) => ReactNode; +} + +function SessionRouteGate({ children, previewRoute, sessionId }: SessionRouteGateProps) { const navigate = useNavigate(); const [nowMs] = useState(() => Date.now()); const [pendingTimedOut, setPendingTimedOut] = useState(false); @@ -105,18 +116,11 @@ function SessionRoute() { const pendingKey = rawDecision.kind === "pending" ? sessionId : null; const decision = applySessionRoutePendingGrace(rawDecision, pendingTimedOut); - // Arm grace only when raw route truth enters pending. A healthy session does - // not burn its future reconnect grace, and an expired pending route remains - // unavailable until raw truth recovers or the route changes. useEffect(() => { pendingGrace.update(pendingKey); }, [pendingGrace, pendingKey]); useEffect(() => () => pendingGrace.dispose(), [pendingGrace]); - // Activation stamps the visit and closes the overlay rail — once per - // present route session ID. Streamed projections rebuild `session` on - // every output/status update; the gate keeps that churn from re-closing - // a rail the user just reopened. const [activationGate] = useState(() => createSessionRouteActivationGate()); useEffect(() => { const target = activationGate.resolve(decision, session); @@ -145,7 +149,13 @@ function SessionRoute() { return ; } if (decision.kind === "redirect-session") { - return ( + return previewRoute ? ( + + ) : ( ); } @@ -197,7 +207,27 @@ function SessionRoute() {
); } - return ; + return children(session, project, nowMs); +} + +function SessionRoute() { + const { sessionId } = useParams({ from: "/sessions/$sessionId" }); + return ( + + {(session, project, nowMs) => ( + + )} + + ); +} + +function PreviewRoute() { + const { sessionId } = useParams({ from: "/sessions/$sessionId/preview" }); + return ( + + {(session, project) => } + + ); } const sessionRoute = createRoute({ @@ -206,6 +236,12 @@ const sessionRoute = createRoute({ component: SessionRoute, }); +const previewRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/sessions/$sessionId/preview", + component: PreviewRoute, +}); + function AgentViewRoute() { const navigate = useNavigate(); const activeSessionId = useWorkspace((state) => state.activeSessionId); @@ -373,6 +409,7 @@ const usageRoute = createRoute({ const routeTree = rootRoute.addChildren([ indexRoute, sessionRoute, + previewRoute, agentViewRoute, settingsRoute, hostsRoute, diff --git a/apps/web/src/state/workspace-store.ts b/apps/web/src/state/workspace-store.ts index d59cbb0..bce3b00 100644 --- a/apps/web/src/state/workspace-store.ts +++ b/apps/web/src/state/workspace-store.ts @@ -22,6 +22,7 @@ export const PANE_FAMILIES = ["agents", "activity", "review", "files", "terminal export type PaneFamily = (typeof PANE_FAMILIES)[number]; export type ThemePreference = "light" | "dark" | "system"; +export type PreviewScaleMode = "fit" | "actual"; /** Per-session view continuity: everything restored on A→B→A switching. */ export interface SessionViewState { @@ -33,6 +34,9 @@ export interface SessionViewState { readonly paneOpen: boolean; readonly paneWidth: number; readonly terminalDrawerOpen: boolean; + /** Preview tab and scale restored on A→B→A route switching. */ + readonly previewId: string | null; + readonly previewScale: PreviewScaleMode; } export const DEFAULT_SESSION_VIEW: SessionViewState = { @@ -42,6 +46,8 @@ export const DEFAULT_SESSION_VIEW: SessionViewState = { paneOpen: false, paneWidth: RIGHT_PANE_WIDTH.defaultWidth, terminalDrawerOpen: false, + previewId: null, + previewScale: "fit", }; interface PersistedWorkspaceState { @@ -95,6 +101,8 @@ export interface WorkspaceActions { setPaneOpen(sessionId: string, open: boolean): void; setPaneWidth(sessionId: string, width: number): void; setTerminalDrawerOpen(sessionId: string, open: boolean): void; + setSessionPreview(sessionId: string, previewId: string | null): void; + setSessionPreviewScale(sessionId: string, scale: PreviewScaleMode): void; } export type WorkspaceStore = WorkspaceState & WorkspaceActions; @@ -155,6 +163,14 @@ function sanitizeSessionView(value: unknown): SessionViewState | null { ? clampWidth(view.paneWidth, RIGHT_PANE_WIDTH) : RIGHT_PANE_WIDTH.defaultWidth, terminalDrawerOpen: view.terminalDrawerOpen === true, + previewId: + typeof view.previewId === "string" && + view.previewId.length > 0 && + view.previewId.length <= 256 && + !/[\u0000-\u001f\u007f]/u.test(view.previewId) + ? view.previewId + : null, + previewScale: view.previewScale === "actual" ? "actual" : "fit", }; } @@ -328,6 +344,10 @@ export function createWorkspaceStore(options: CreateWorkspaceStoreOptions): Work ), setTerminalDrawerOpen: (sessionId, open) => set((state) => updateSessionView(state, sessionId, { terminalDrawerOpen: open })), + setSessionPreview: (sessionId, previewId) => + set((state) => updateSessionView(state, sessionId, { previewId })), + setSessionPreviewScale: (sessionId, previewScale) => + set((state) => updateSessionView(state, sessionId, { previewScale })), })); store.subscribe((state) => persistence.save(toPersistedWorkspace(state))); diff --git a/apps/web/test/panes-activity.test.ts b/apps/web/test/panes-activity.test.ts index fcab2ba..2a6afa5 100644 --- a/apps/web/test/panes-activity.test.ts +++ b/apps/web/test/panes-activity.test.ts @@ -1,10 +1,12 @@ // Activity stream contract: classification, filters, search, pause without // loss, secret redaction, unknown-event fallback, and export shape. import { describe, expect, it } from "vite-plus/test"; +import type { PreviewEventProjection } from "@t4-code/client"; import { ACTIVITY_RETENTION_LIMIT, appendActivity, + classifyPreviewEvent, classifySessionEvent, exportActivity, redactPayload, @@ -111,6 +113,56 @@ describe("event classification", () => { }); }); +describe("preview event classification", () => { + it("keeps only origin/path metadata, capture identity, and generic failures", () => { + const launch: PreviewEventProjection = { + type: "launch", + previewId: "preview-1", + cursor: { epoch: "preview", seq: 1 }, + url: { origin: "https://preview.test", pathname: "/workspace", hasQuery: true }, + }; + const capture: PreviewEventProjection = { + type: "capture", + previewId: "preview-1", + cursor: { epoch: "preview", seq: 2 }, + captureId: "capture-2", + timestamp: 1_784_392_800_000, + }; + const error: PreviewEventProjection = { + type: "error", + previewId: "preview-1", + cursor: { epoch: "preview", seq: 3 }, + errorCode: "backend_message_must_not_render", + }; + + expect(classifyPreviewEvent(launch, 1, AT, "cached")).toMatchObject({ + kind: "system", + title: "Browser preview launched", + detail: "https://preview.test/workspace · cached", + raw: { url: "https://preview.test/workspace", freshness: "cached" }, + }); + expect(classifyPreviewEvent(capture, 2, AT)).toMatchObject({ + at: "2026-07-18T16:40:00.000Z", + title: "Browser preview captured", + detail: "Capture capture-2", + raw: { captureId: "capture-2", timestamp: 1_784_392_800_000 }, + }); + const classifiedError = classifyPreviewEvent(error, 3, AT); + expect(classifiedError).toMatchObject({ + kind: "error", + title: "Browser preview failed", + detail: "The browser preview request did not complete.", + }); + const serialized = exportActivity([ + { ...classifyPreviewEvent(launch, 1, AT), seq: 1 }, + { ...classifiedError, seq: 2 }, + ]); + expect(serialized).not.toContain("token=never"); + expect(serialized).not.toContain("#never"); + expect(serialized).not.toContain("backend_message_must_not_render"); + }); +}); + describe("filters, search, pause", () => { const entries = [ entry(1, { kind: "tool", title: "grep epoch" }), diff --git a/apps/web/test/panes-live.test.ts b/apps/web/test/panes-live.test.ts index 8fd5363..fe736f7 100644 --- a/apps/web/test/panes-live.test.ts +++ b/apps/web/test/panes-live.test.ts @@ -190,6 +190,24 @@ function responseFrame( }; } +function previewFrame( + type: "preview.launch" | "preview.navigation", + seq: number, + url: string, +): ProjectionFrame { + return { + v: PROTOCOL_VERSION, + type, + hostId: brandHostId(HOST), + sessionId: brandSessionId(SESSION), + previewId: "preview-1", + state: "ready", + url, + revision: brandRevision(`preview-${seq}`), + cursor: { epoch: "preview-epoch", seq }, + } as ProjectionFrame; +} + function gapFrame(): GapFrame { return { v: PROTOCOL_VERSION, @@ -567,6 +585,26 @@ describe("live projection populates each family", () => { expect(kinds).toEqual(["tool", "system", "error"]); }); + it("adds sanitized preview activity once across projection replays", () => { + const fake = new FakeRuntime(); + const projection = project([ + previewFrame("preview.launch", 1, "https://preview.test/launch?token=never#secret"), + previewFrame("preview.navigation", 2, "https://preview.test/next?token=never#secret"), + ]); + fake.setProjection(projection); + const store = createLiveInspectorStore(fake, VIEW_ID); + expect(store.getState().activity.map((entry) => entry.title)).toEqual([ + "Browser preview launched", + "Browser preview navigated", + ]); + const exported = JSON.stringify(store.getState().activity); + expect(exported).not.toContain("token=never"); + expect(exported).not.toContain("#secret"); + + fake.setProjection(projection); + expect(store.getState().activity).toHaveLength(2); + }); + it("review rows come from review frames and never fabricate a diff", () => { const fake = new FakeRuntime(); fake.setProjection( diff --git a/apps/web/test/preview-workspace.test.ts b/apps/web/test/preview-workspace.test.ts new file mode 100644 index 0000000..b11f768 --- /dev/null +++ b/apps/web/test/preview-workspace.test.ts @@ -0,0 +1,239 @@ +import { + PreviewCaptureResource, + type DesktopRuntimeController, + type PreviewProjection, +} from "@t4-code/client"; +import type { CommandResult } from "@t4-code/protocol/desktop-ipc"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + choosePreview, + defaultLaunchAuthority, + derivePreviewWorkspaceStatus, + displayedToNativeCoordinate, + isProjectRelativeUploadPath, + previewHostSupport, + parsePreviewPolicyDecision, + previewActionSupport, +} from "../src/features/preview/preview-model.ts"; +import { PreviewDesktopAdapter } from "../src/features/preview/preview-runtime.ts"; + +const identity = { hostId: "host-a", sessionId: "session-a", previewId: "preview-a" }; +const address = { targetId: "target-a", hostId: identity.hostId, sessionId: identity.sessionId }; + +function preview(patch: Partial = {}): PreviewProjection { + return { + ...identity, + revision: "1", + cursor: "cursor" as unknown as PreviewProjection["cursor"], + state: "ready", + freshness: "fresh", + availableActions: ["navigate", "click", "capture", "upload"], + ...patch, + }; +} + +function accepted(result: unknown): CommandResult { + return { + targetId: address.targetId, + requestId: "request" as CommandResult["requestId"], + commandId: "command" as CommandResult["commandId"], + accepted: true, + result, + }; +} + +describe("preview workspace policy", () => { + it("derives launching, ready, running, stopped, failed, cached, offline, and unsupported states", () => { + expect(derivePreviewWorkspaceStatus({ preview: undefined, connected: true, supported: true })).toBe("empty"); + expect(derivePreviewWorkspaceStatus({ preview: preview({ state: "launching" }), connected: true, supported: true })).toBe("launching"); + expect(derivePreviewWorkspaceStatus({ preview: preview({ state: "running" }), connected: true, supported: true })).toBe("running"); + expect(derivePreviewWorkspaceStatus({ preview: preview({ state: "stopped" }), connected: true, supported: true })).toBe("stopped"); + expect(derivePreviewWorkspaceStatus({ preview: preview({ state: "failed" }), connected: true, supported: true })).toBe("failed"); + expect(derivePreviewWorkspaceStatus({ preview: preview({ freshness: "cached" }), connected: true, supported: true })).toBe("cached"); + expect(derivePreviewWorkspaceStatus({ preview: preview(), connected: false, supported: true })).toBe("offline"); + expect(derivePreviewWorkspaceStatus({ preview: preview(), connected: true, supported: false })).toBe("unsupported"); + }); + + it("does not auto-select an authenticated profile or launch authority", () => { + const authenticated = preview({ previewId: "authenticated", authority: { id: "auth", label: "Personal", kind: "authenticated-profile", requiresExplicitOptIn: true } }); + const isolated = preview({ previewId: "isolated", authority: { id: "omp-session", label: "Session", kind: "isolated-session", requiresExplicitOptIn: false } }); + expect(choosePreview([authenticated, isolated], null)?.previewId).toBe("isolated"); + expect(choosePreview([authenticated], null)).toBeUndefined(); + expect(choosePreview([authenticated, isolated], "authenticated")?.previewId).toBe("authenticated"); + expect(defaultLaunchAuthority()).toBe("omp-session"); + }); + + it("reports host-advertised action reasons and scales snapshot clicks to native coordinates", () => { + const current = preview({ availableActions: ["navigate"] }); + const { availableActions: _availableActions, ...withoutActions } = preview(); + expect(previewActionSupport(current, "click", "ready", true)).toEqual({ + supported: false, + reason: "This host does not advertise click for this preview.", + }); + expect(previewActionSupport(withoutActions, "navigate", "ready", true)).toEqual({ + supported: false, + reason: "This host does not advertise navigate for this preview.", + }); + expect( + displayedToNativeCoordinate({ x: 50, y: 25 }, { width: 100, height: 50 }, { width: 1000, height: 500 }), + ).toEqual({ x: 500, y: 250 }); + }); + + it("rejects absolute and parent-traversal uploads before sending them", () => { + expect(isProjectRelativeUploadPath("assets/image.png")).toBe(true); + expect(isProjectRelativeUploadPath("/tmp/image.png")).toBe(false); + expect(isProjectRelativeUploadPath("C:\\temp\\image.png")).toBe(false); + expect(isProjectRelativeUploadPath("../image.png")).toBe(false); + }); + + it("requires preview control, read, and input grants", () => { + expect(previewHostSupport(undefined)).toEqual({ + supported: false, + inputSupported: false, + reason: "This host does not advertise browser preview control.", + }); + expect( + previewHostSupport({ + grantedFeatures: ["preview.control"], + grantedCapabilities: ["preview.read"], + }), + ).toEqual({ supported: true, inputSupported: false }); + expect( + previewHostSupport({ + grantedFeatures: ["preview.control"], + grantedCapabilities: ["preview.read", "preview.input"], + }), + ).toEqual({ supported: true, inputSupported: true }); + }); + + it("keeps policy checks to allowed and safe reason fields", () => { + expect( + parsePreviewPolicyDecision({ + allowed: true, + confirmationRequired: true, + reason: "Confirm navigation", + confirmationId: "confirmation", + commandId: "command", + }), + ).toEqual({ allowed: true, reason: "Confirm navigation" }); + }); +}); + +describe("preview desktop adapter", () => { + it("acquires a matching cooperative lease and passes its id to mutations", async () => { + const command = vi.fn(async (_targetId: string, intent: { command: string; args: Record }) => { + if (intent.command === "preview.lease.acquire") { + return accepted({ previewId: identity.previewId, leaseId: "lease-a", expiresAt: Date.now() + 30_000 }); + } + return accepted({}); + }); + const controller = { command, confirm: vi.fn() } as unknown as DesktopRuntimeController; + const adapter = new PreviewDesktopAdapter(controller, address); + + await adapter.mutate("navigate", identity, { url: "https://example.test" }); + + expect(command).toHaveBeenCalledWith( + address.targetId, + expect.objectContaining({ + command: "preview.navigate", + args: expect.objectContaining({ previewId: identity.previewId, leaseId: "lease-a", url: "https://example.test" }), + }), + ); + }); + + it("releases every cooperative lease when the workspace adapter is disposed", async () => { + const command = vi.fn(async (_targetId: string, intent: { command: string }) => { + if (intent.command === "preview.lease.acquire") { + return accepted({ previewId: identity.previewId, leaseId: "lease-a", expiresAt: Date.now() + 30_000 }); + } + return accepted({}); + }); + const adapter = new PreviewDesktopAdapter( + { command, confirm: vi.fn() } as unknown as DesktopRuntimeController, + address, + ); + + await adapter.mutate("navigate", identity, { url: "https://example.test" }); + await adapter.dispose(); + + expect(command).toHaveBeenCalledWith( + address.targetId, + expect.objectContaining({ command: "preview.lease.release" }), + ); + }); + + it("routes a projected preview confirmation through the controller", async () => { + const confirm = vi.fn(async () => ({ accepted: true })); + const adapter = new PreviewDesktopAdapter( + { command: vi.fn(), confirm } as unknown as DesktopRuntimeController, + address, + ); + const challenge = { + confirmationId: "confirmation-a" as never, + commandId: "command-a" as never, + hostId: identity.hostId as never, + sessionId: identity.sessionId as never, + summary: "preview.navigate", + }; + + await adapter.confirm(challenge, "approve"); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + confirmationId: "confirmation-a", + commandId: "command-a", + decision: "approve", + }), + ); + }); + + it("rejects absolute upload paths before acquiring a lease", async () => { + const command = vi.fn(); + const adapter = new PreviewDesktopAdapter( + { command, confirm: vi.fn() } as unknown as DesktopRuntimeController, + address, + ); + + await expect(adapter.mutate("upload", identity, { selector: "input", path: "/tmp/file.png" })).rejects.toThrow( + "project-relative", + ); + expect(command).not.toHaveBeenCalled(); + }); + + it("releases object URLs when a capture is replaced or disposed", async () => { + const png = new Uint8Array(24); + png.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]); + png[19] = 1; + png[23] = 1; + const revoked: string[] = []; + const resource = new PreviewCaptureResource({ + read: async (_preview, captureId, offset) => ({ + previewId: identity.previewId, + captureId, + size: png.byteLength, + offset, + nextOffset: png.byteLength, + complete: true, + content: Buffer.from(png).toString("base64"), + }), + sha256: async () => "a".repeat(64), + createObjectURL: () => "blob:preview", + revokeObjectURL: (url) => revoked.push(url), + }); + const capture = { + captureId: "capture-a", + mimeType: "image/png" as const, + size: png.byteLength, + width: 1, + height: 1, + capturedAt: 1, + sha256: "a".repeat(64), + }; + + await resource.objectUrl(identity, capture); + resource.release(identity); + + expect(revoked).toEqual(["blob:preview"]); + }); +}); diff --git a/apps/web/test/tool-renderers.test.tsx b/apps/web/test/tool-renderers.test.tsx index be5d4e3..e3ac87d 100644 --- a/apps/web/test/tool-renderers.test.tsx +++ b/apps/web/test/tool-renderers.test.tsx @@ -16,6 +16,7 @@ import { ResultImages, } from "../src/features/transcript/tool-render/parts.tsx"; import type { ToolRenderProps } from "../src/features/transcript/tool-render/types.ts"; +import { sessionPreviewDestination } from "../src/features/transcript/SessionMain.tsx"; import { initialProjection, reduceTranscript } from "../src/features/transcript/projection.ts"; import { deriveTranscriptRows } from "../src/features/transcript/rows.ts"; @@ -759,4 +760,30 @@ describe("OMP semantic tool renderers", () => { ); expect(images).toBe(""); }); + it("offers the live session preview without changing export renderers", () => { + const renderer = resolveToolRenderer("browser"); + const Body = renderer.Body; + expect(Body).toBeDefined(); + let openCount = 0; + const props: ToolRenderProps = { + name: "browser", + args: { action: "open", url: "https://example.test/" }, + host: { + openPreview: () => { + openCount += 1; + }, + }, + }; + const live = Body === undefined ? "" : renderToStaticMarkup(); + expect(live).toContain("Open Preview"); + expect(live).toContain('aria-label="Open browser preview for this session"'); + expect(openCount).toBe(0); + const exported = + Body === undefined ? "" : renderToStaticMarkup(); + expect(exported).not.toContain("Open Preview"); + expect(sessionPreviewDestination("host-a/session-a")).toEqual({ + params: { sessionId: "host-a/session-a" }, + to: "/sessions/$sessionId/preview", + }); + }); }); diff --git a/apps/web/test/workspace-store.test.ts b/apps/web/test/workspace-store.test.ts index 5a131b0..50aa7bf 100644 --- a/apps/web/test/workspace-store.test.ts +++ b/apps/web/test/workspace-store.test.ts @@ -22,7 +22,7 @@ function makeStore(initialPersisted?: unknown) { } describe("session continuity (A→B→A)", () => { - it("preserves scroll, draft, pane family/open/width, and drawer per session", () => { + it("preserves transcript, pane, drawer, and preview state per session", () => { const { store } = makeStore(); const s = () => store.getState(); @@ -32,12 +32,15 @@ describe("session continuity (A→B→A)", () => { s().togglePaneFamily("A", "review"); s().setPaneWidth("A", 500); s().setTerminalDrawerOpen("A", true); + s().setSessionPreview("A", "preview-a"); + s().setSessionPreviewScale("A", "actual"); s().activateSession("B", "2026-07-11T10:01:00Z"); s().setSessionDraft("B", "other draft"); s().togglePaneFamily("B", "files"); s().togglePaneFamily("B", "files"); // close again s().setSessionScrollTop("B", 7); + s().setSessionPreview("B", "preview-b"); s().activateSession("A", "2026-07-11T10:02:00Z"); const viewA = selectSessionView(s(), "A"); @@ -47,6 +50,8 @@ describe("session continuity (A→B→A)", () => { expect(viewA.paneOpen).toBe(true); expect(viewA.paneWidth).toBe(500); expect(viewA.terminalDrawerOpen).toBe(true); + expect(viewA.previewId).toBe("preview-a"); + expect(viewA.previewScale).toBe("actual"); const viewB = selectSessionView(s(), "B"); expect(viewB.draft).toBe("other draft"); @@ -54,6 +59,8 @@ describe("session continuity (A→B→A)", () => { expect(viewB.paneOpen).toBe(false); expect(viewB.scrollTop).toBe(7); expect(viewB.terminalDrawerOpen).toBe(false); + expect(viewB.previewId).toBe("preview-b"); + expect(viewB.previewScale).toBe("fit"); }); it("returns defaults for sessions never touched", () => { @@ -116,12 +123,16 @@ describe("persistence", () => { first.getState().setRailWidth(300); first.getState().setTheme("dark"); first.getState().setEmptyProjectDismissed("host/project", true); + first.getState().setSessionPreview("A", "preview-a"); + first.getState().setSessionPreviewScale("A", "actual"); first.getState().setPaletteOpen(true); // ephemeral, must not persist const second = createWorkspaceStore({ persistence }); const state = second.getState(); expect(state.activeSessionId).toBe("A"); expect(selectSessionView(state, "A").draft).toBe("resume me"); + expect(selectSessionView(state, "A").previewId).toBe("preview-a"); + expect(selectSessionView(state, "A").previewScale).toBe("actual"); expect(state.railWidth).toBe(300); expect(state.theme).toBe("dark"); expect(state.dismissedEmptyProjectIds).toEqual({ "host/project": true }); @@ -168,6 +179,10 @@ describe("persistence", () => { paneOpen: true, paneWidth: 400, }); + expect(parsed?.sessionViewById.A).toMatchObject({ + previewId: null, + previewScale: "fit", + }); }); it("rejects wrong versions and non-objects", () => { @@ -187,7 +202,14 @@ describe("persistence", () => { dismissedEmptyProjectIds: { good: true, falseEntry: false, bad: "yes" }, lastVisitedAtBySessionId: { good: "2026-07-11T10:00:00Z", bad: "not a date" }, sessionViewById: { - good: { paneFamily: "made-up", paneWidth: 5, scrollTop: -3, draft: 9 }, + good: { + paneFamily: "made-up", + paneWidth: 5, + scrollTop: -3, + draft: 9, + previewId: "bad\u0000id", + previewScale: "giant", + }, bad: null, }, }); @@ -204,6 +226,8 @@ describe("persistence", () => { expect(view?.paneWidth).toBe(RIGHT_PANE_WIDTH.minWidth); expect(view?.scrollTop).toBeNull(); expect(view?.draft).toBe(""); + expect(view?.previewId).toBeNull(); + expect(view?.previewScale).toBe("fit"); expect(parsed?.sessionViewById["bad"]).toBeUndefined(); }); diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index 0026fc7..0bdeb33 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -2,20 +2,20 @@ "appProtocol": "omp-app/1", "appWire": { "package": "@oh-my-pi/app-wire", - "version": "0.5.9", + "version": "0.6.0", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "5633bdd7e5f9062d1822eeeddb9311b2d942bf6f", - "sourceTreeHash": "4d8794bad6fc57d86058a46dc4698fcca14263e5", - "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz", - "tarballSha256": "b3a891610e919833d16302b1893831f509d264322c3869d28f17adbbff6116f0", - "goldenCorpusSha256": "50b087a3a22bb48908718b7786eff6ce618bbd6b6123c055e40c957ef47a805c" + "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499", + "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz", + "tarballSha256": "92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c", + "goldenCorpusSha256": "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1" }, "publishedAppWire": { "package": "@oh-my-pi/app-wire", - "version": "0.5.9", + "version": "0.6.0", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "5633bdd7e5f9062d1822eeeddb9311b2d942bf6f", - "sourceTreeHash": "4d8794bad6fc57d86058a46dc4698fcca14263e5" + "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499" }, "publishedRuntime": { "package": "omp", diff --git a/e2e/remote-app.spec.ts b/e2e/remote-app.spec.ts index c1c1b7e..2890413 100644 --- a/e2e/remote-app.spec.ts +++ b/e2e/remote-app.spec.ts @@ -370,7 +370,6 @@ async function openSession(page: Page, mobile: boolean): Promise { } test.describe.configure({ mode: "serial" }); - test("routes mobile session creation to the selected profile and preserves both profiles", async ({ page, }) => { @@ -1427,3 +1426,33 @@ test("manages a session from a phone and converges another live client", async ( await observerContext.close(); } }); + +test("opens a session-linked browser preview, captures a snapshot, and keeps controls mobile-safe", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openSession(page, false); + + const openPreview = page.getByRole("button", { + name: "Open browser preview for this session", + }); + await expect(openPreview).toBeVisible(); + await openPreview.click(); + await expect(page).toHaveURL(/#\/sessions\/[^/]+\/preview$/u); + await expect(page.getByRole("heading", { name: "Browser preview" })).toBeVisible(); + await expect(page.locator(".surface-subheader").getByText("Ready", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Recapture" }).click(); + const snapshot = page.getByRole("img", { name: "Browser preview snapshot: Fixture preview" }); + await expect(snapshot).toBeVisible(); + await expect(snapshot).toHaveAttribute("src", /^blob:/u); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("heading", { name: "Browser preview" })).toBeVisible(); + const recaptureBox = await page.getByRole("button", { name: "Recapture" }).boundingBox(); + expect(recaptureBox).not.toBeNull(); + expect(recaptureBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 46def8c..6ed9f4b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,4 +1,18 @@ export { OmpClient, createOmpClient } from "./omp-client-runtime.ts"; +export type { + PreviewCommandTarget, + PreviewLaunchIntent, + PreviewNavigateIntent, + PreviewClickIntent, + PreviewScrollIntent, + PreviewTypeIntent, + PreviewFillIntent, + PreviewSelectIntent, + PreviewUploadIntent, + PreviewPressIntent, + PreviewPolicyCheckIntent, + PreviewHandoffIntent, +} from "./omp-client-runtime.ts"; export { ompAppV1ProtocolProvider, } from "./omp-app-v1-protocol-provider.ts"; @@ -72,11 +86,34 @@ export { MAX_RETAINED_FILES, MAX_RETAINED_FILES_BYTES, MAX_RETAINED_FILE_BYTES, + MAX_RETAINED_PREVIEWS, + MAX_RETAINED_PREVIEW_EVENTS, } from "./projection.ts"; +export { + PreviewCaptureResource, + PreviewLeaseManager, + previewKey, + PREVIEW_CAPTURE_MAX_BYTES, + PREVIEW_CAPTURE_MAX_PIXELS, + PREVIEW_CAPTURE_READ_CHUNK_BYTES, +} from "./preview.ts"; +export type { + PreviewIdentity, + PreviewLeaseIdentity, + PreviewCaptureMetadata, + PreviewCaptureReadResult, + PreviewCaptureResourceOptions, + PreviewLeaseManagerClient, + PreviewLeaseManagerOptions, +} from "./preview.ts"; export type { ProjectionFrame, ProjectionEventFrame, ProjectionFreshness, + PreviewFreshness, + PreviewProjection, + PreviewAuthorityProjection, + PreviewEventProjection, TerminalProjection, ResultProjection, AgentTranscriptProjection, diff --git a/packages/client/src/omp-client-runtime.ts b/packages/client/src/omp-client-runtime.ts index 5d1dd28..ac21ae6 100644 --- a/packages/client/src/omp-client-runtime.ts +++ b/packages/client/src/omp-client-runtime.ts @@ -1,11 +1,5 @@ -import { - hostId, - sessionId, - type Cursor, - type HostId, - type SessionId, -} from "@t4-code/protocol"; -import type { ProjectionStore } from "./projection.ts"; +import { hostId, sessionId, type Cursor, type HostId, type SessionId } from "@t4-code/protocol"; +import type { PreviewProjection, ProjectionStore } from "./projection.ts"; import { boundedMetadata, DefaultClock, @@ -42,7 +36,12 @@ import { PendingRequests } from "./omp-client-pending.ts"; import { ClientTimerRegistry } from "./omp-client-timers.ts"; import { OmpClientEvents } from "./omp-client-events.ts"; import { OmpClientConnection } from "./omp-client-connection.ts"; -import { decodeProviderServerEvent, OmpClientEventDispatcher, safeFrameDecodeFailure, sendClientHello } from "./omp-client-frames.ts"; +import { + decodeProviderServerEvent, + OmpClientEventDispatcher, + safeFrameDecodeFailure, + sendClientHello, +} from "./omp-client-frames.ts"; import { OmpClientReconnectHealth } from "./omp-client-reconnect-health.ts"; import { encodeOutgoingMessage } from "./omp-client-outbound.ts"; import { resolveOmpProtocolProvider } from "./omp-protocol-provider-registry.ts"; @@ -57,13 +56,100 @@ import { import { handleResponseFrame } from "./omp-client-response.ts"; import { isLegalClientTransition } from "./omp-client-state.ts"; export * from "./omp-client-contracts.ts"; +import { + PreviewCaptureResource, + PreviewLeaseManager, + previewKey, + type PreviewCaptureMetadata, + type PreviewCaptureReadResult, + type PreviewIdentity, + type PreviewLeaseIdentity, +} from "./preview.ts"; +export * from "./preview.ts"; export * from "./projection.ts"; export * from "./projection-cache.ts"; export * from "./desktop-runtime.ts"; type PendingResult = OmpResponse | OmpPairOk; type DurableEvent = OmpServerEventOf<"entry" | "event" | "session.delta">; -type PublicEvent = Extract; +type PublicEvent = Extract< + PublicOmpServerEvent, + { kind: Kind } +>; +export interface PreviewCommandTarget extends PreviewIdentity { + readonly leaseId?: string; +} +export interface PreviewLaunchIntent { + readonly hostId: string; + readonly sessionId: string; + readonly url: string; + readonly authorityId?: string; +} +export interface PreviewNavigateIntent extends PreviewCommandTarget { + readonly url: string; +} +export interface PreviewClickIntent extends PreviewCommandTarget { + readonly x?: number; + readonly y?: number; + readonly selector?: string; + readonly button?: "left" | "middle" | "right"; + readonly clickCount?: number; +} +export interface PreviewScrollIntent extends PreviewCommandTarget { + readonly deltaX: number; + readonly deltaY: number; + readonly selector?: string; +} +export interface PreviewTypeIntent extends PreviewCommandTarget { + readonly text: string; + readonly selector?: string; +} +export interface PreviewFillIntent extends PreviewCommandTarget { + readonly text: string; + readonly selector?: string; +} +export interface PreviewSelectIntent extends PreviewCommandTarget { + readonly selector: string; + readonly value: string; +} +export interface PreviewUploadIntent extends PreviewCommandTarget { + readonly selector: string; + readonly path: string; +} +export interface PreviewPressIntent extends PreviewCommandTarget { + readonly key: string; +} +export interface PreviewPolicyCheckIntent { + readonly hostId: string; + readonly sessionId: string; + readonly action: + | "activate" + | "navigate" + | "back" + | "forward" + | "reload" + | "close" + | "capture" + | "click" + | "fill" + | "type" + | "press" + | "scroll" + | "select" + | "upload" + | "handoff"; + readonly previewId?: string; + readonly url?: string; + readonly authorityId?: string; +} +export interface PreviewHandoffIntent extends PreviewCommandTarget { + readonly message: string; + readonly mode?: "manual" | "selector" | "url" | "text"; + readonly selector?: string; + readonly urlSubstring?: string; + readonly text?: string; + readonly timeoutMs?: number; +} interface ConnectWaiter { resolve: () => void; reject: (error: OmpClientError) => void; @@ -87,6 +173,8 @@ export class OmpClient { private readonly inboundDispatcher: OmpClientEventDispatcher; private readonly events = new OmpClientEvents(); private readonly attached = new Map(); + private readonly previewCaptures: PreviewCaptureResource; + readonly previewLeaseManager: PreviewLeaseManager; private handshakeTimer: ClientTimer | undefined; private heartbeatNonce: string | undefined; private stateValue: OmpClientState = "idle"; @@ -95,6 +183,8 @@ export class OmpClient { private readonly desyncedSessions = new Set(); private authenticationValue: "local" | "pairing-required" | "paired" | undefined; private granted = new Set(); + private previewStateGeneration: number | undefined; + private readonly previewStateSessions = new Set(); private closedByUser = false; private compatibilityFallbackUsed = false; private connectWaiters: ConnectWaiter[] = []; @@ -108,9 +198,15 @@ export class OmpClient { this.clock = options.clock ?? new DefaultClock(); this.ids = options.ids ?? new DefaultIds(); this.projection = options.projection; + this.previewCaptures = new PreviewCaptureResource({ + read: async (identity, captureId, offset) => + this.readPreviewCapture(identity, captureId, offset), + }); + this.previewLeaseManager = new PreviewLeaseManager(this, { now: () => this.clock.now() }); this.random = options.random ?? Math.random; this.targetHost = options.hostId === undefined ? undefined : hostId(options.hostId); - this.expectedHost = options.expectedHostId === undefined ? this.targetHost : hostId(options.expectedHostId); + this.expectedHost = + options.expectedHostId === undefined ? this.targetHost : hostId(options.expectedHostId); this.timerRegistry = new ClientTimerRegistry(this.timers); this.cursorJournal = new CursorJournal( options.cursorStore, @@ -156,11 +252,29 @@ export class OmpClient { this.inboundDispatcher = new OmpClientEventDispatcher({ welcome: (message) => this.handleWelcome(message), pong: (nonce) => this.handlePong(nonce), - bye: (message) => { if (message.payload.retryable) this.handleDisconnect(undefined, message.payload.reason); else this.fatal(this.error(message.payload.code.toLowerCase().includes("auth") ? "auth" : "protocol", "server closed the protocol session")); }, + bye: (message) => { + if (message.payload.retryable) this.handleDisconnect(undefined, message.payload.reason); + else + this.fatal( + this.error( + message.payload.code.toLowerCase().includes("auth") ? "auth" : "protocol", + "server closed the protocol session", + ), + ); + }, response: (message) => this.handleResponse(message), pairOk: (message, generation) => this.handlePairOk(message, generation), - pairError: (message) => { if (message.payload.requestId !== undefined) this.settlePairError(message); this.publish(message); }, - gap: (message) => { this.markDesynced(sessionKey(String(message.payload.hostId), String(message.payload.sessionId)), "cursor gap requires a snapshot"); this.publish(message); }, + pairError: (message) => { + if (message.payload.requestId !== undefined) this.settlePairError(message); + this.publish(message); + }, + gap: (message) => { + this.markDesynced( + sessionKey(String(message.payload.hostId), String(message.payload.sessionId)), + "cursor gap requires a snapshot", + ); + this.publish(message); + }, snapshot: (message) => this.acceptSnapshot(message), durable: (message) => { // Host-wide session-index deltas have their own per-session ordering in @@ -192,7 +306,9 @@ export class OmpClient { ...(this.targetHost === undefined ? {} : { hostId: String(this.targetHost) }), ...(this.epochValue === undefined ? {} : { epoch: this.epochValue }), ...(this.cursorValue === undefined ? {} : { cursor: freeze({ ...this.cursorValue }) }), - ...(this.authenticationValue === undefined ? {} : { authentication: this.authenticationValue }), + ...(this.authenticationValue === undefined + ? {} + : { authentication: this.authenticationValue }), desynced: this.desyncedSessions.size > 0, }); } @@ -208,16 +324,24 @@ export class OmpClient { }; } - onState(listener: (snapshot: OmpStateSnapshot) => void): Unsubscribe { return this.events.onState(listener); } - onEvent(listener: (event: PublicOmpServerEvent) => void): Unsubscribe { return this.events.onEvent(listener); } - onError(listener: (error: OmpClientError) => void): Unsubscribe { return this.events.onError(listener); } + onState(listener: (snapshot: OmpStateSnapshot) => void): Unsubscribe { + return this.events.onState(listener); + } + onEvent(listener: (event: PublicOmpServerEvent) => void): Unsubscribe { + return this.events.onEvent(listener); + } + onError(listener: (error: OmpClientError) => void): Unsubscribe { + return this.events.onError(listener); + } async connect(): Promise { if (isTerminalState(this.stateValue)) throw this.error("closed", "client is closed"); await this.cursorJournal.load(); if (this.stateValue === "ready") return; if (isTerminalState(this.stateValue)) throw this.error("closed", "client is closed"); - const ready = new Promise((resolve, reject) => this.connectWaiters.push({ resolve, reject })); + const ready = new Promise((resolve, reject) => + this.connectWaiters.push({ resolve, reject }), + ); this.closedByUser = false; if (this.stateValue === "idle") { // The transport factory may itself await a WebSocket/Unix-socket open. @@ -247,10 +371,7 @@ export class OmpClient { this.reviveRetryableTransportFailure(); return; } - if ( - (this.stateValue === "ready" || this.stateValue === "pairing") && - this.inboundIsStale() - ) { + if ((this.stateValue === "ready" || this.stateValue === "pairing") && this.inboundIsStale()) { this.reconnectNow(); } } @@ -274,7 +395,8 @@ export class OmpClient { this.stateValue === "closing" || this.stateValue === "connecting" || this.stateValue === "handshaking" - ) return; + ) + return; // Replacing a socket starts a new recovery episode. // Attempts remain charged until heartbeat and replay recovery. @@ -284,6 +406,7 @@ export class OmpClient { async close(): Promise { if (this.stateValue === "closed") return; + if (this.stateValue === "ready") await this.previewLeaseManager.releaseAll(); this.closedByUser = true; this.fatalError = undefined; this.clearInbound(); @@ -295,20 +418,302 @@ export class OmpClient { this.clearAllTimers(); this.pendingRequests.rejectAll(closeError); this.connection.disconnect(); + this.previewCaptures.dispose(); await this.cursorJournal.waitForSaves(); this.transition("closed"); this.events.clear(); } - command(intent: CommandIntent, options: CommandOptions = {}): Promise { - return this.sendCommand(intent, options); + async command(intent: CommandIntent, options: CommandOptions = {}): Promise { + const generation = this.generation; + const response = await this.sendCommand(intent, options); + if ( + response.ok && + intent.command === "session.attach" && + intent.hostId !== undefined && + intent.sessionId !== undefined + ) { + this.requestPreviewState( + { hostId: hostId(intent.hostId), sessionId: sessionId(intent.sessionId) }, + generation, + ); + } + return response; } attach(host: string, session: string, options: CommandOptions = {}): Promise { - return this.sendCommand({ hostId: host, sessionId: session, command: "session.attach", args: {} }, options); + return this.command( + { hostId: host, sessionId: session, command: "session.attach", args: {} }, + options, + ); + } + + preview(identity: PreviewCommandTarget): PreviewProjection | undefined { + return this.projection?.snapshot.sessions + .get(sessionKey(identity.hostId, identity.sessionId)) + ?.previews.get(previewKey(identity)); + } + + previewCaptureObjectUrl( + identity: PreviewCommandTarget, + capture: PreviewCaptureMetadata, + ): Promise { + return this.previewCaptures.objectUrl(identity, capture); + } + + releasePreviewCapture(identity: PreviewCommandTarget): void { + this.previewCaptures.release(identity); + } + + previewLaunch(intent: PreviewLaunchIntent, options: CommandOptions = {}): Promise { + return this.sendCommand( + { + hostId: intent.hostId, + sessionId: intent.sessionId, + command: "preview.launch", + args: { + url: intent.url, + ...(intent.authorityId === undefined ? {} : { authorityId: intent.authorityId }), + }, + }, + options, + ); + } + + previewState( + hostId: string, + sessionId: string, + previewId?: string, + options: CommandOptions = {}, + ): Promise { + return this.sendCommand( + { + hostId, + sessionId, + command: "preview.state", + args: previewId === undefined ? {} : { previewId }, + }, + options, + ); + } + + previewActivate( + identity: PreviewCommandTarget, + options: CommandOptions = {}, + ): Promise { + return this.previewTargetCommand("preview.activate", identity, {}, options); + } + + previewNavigate( + intent: PreviewNavigateIntent, + options: CommandOptions = {}, + ): Promise { + return this.sendCommand( + { + hostId: intent.hostId, + sessionId: intent.sessionId, + command: "preview.navigate", + args: { + previewId: intent.previewId, + url: intent.url, + ...(intent.leaseId === undefined ? {} : { leaseId: intent.leaseId }), + }, + }, + options, + ); + } + + previewBack(identity: PreviewCommandTarget, options: CommandOptions = {}): Promise { + return this.previewTargetCommand("preview.back", identity, {}, options); + } + + previewForward( + identity: PreviewCommandTarget, + options: CommandOptions = {}, + ): Promise { + return this.previewTargetCommand("preview.forward", identity, {}, options); + } + + previewReload( + identity: PreviewCommandTarget, + options: CommandOptions = {}, + ): Promise { + return this.previewTargetCommand("preview.reload", identity, {}, options); + } + + previewClose(identity: PreviewCommandTarget, options: CommandOptions = {}): Promise { + return this.previewTargetCommand("preview.close", identity, {}, options); + } + + previewCapture( + identity: PreviewCommandTarget, + options: CommandOptions = {}, + ): Promise { + return this.previewTargetCommand("preview.capture", identity, {}, options); + } + + previewCaptureRead( + identity: PreviewCommandTarget, + captureId: string, + offset: number, + options: CommandOptions = {}, + ): Promise { + return this.previewTargetCommand( + "preview.capture.read", + identity, + { captureId, offset }, + options, + ); + } + + previewClick(intent: PreviewClickIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.click", + intent, + { + ...(intent.x === undefined ? {} : { x: intent.x }), + ...(intent.y === undefined ? {} : { y: intent.y }), + ...(intent.selector === undefined ? {} : { selector: intent.selector }), + ...(intent.button === undefined ? {} : { button: intent.button }), + ...(intent.clickCount === undefined ? {} : { clickCount: intent.clickCount }), + }, + options, + ); + } + + previewScroll(intent: PreviewScrollIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.scroll", + intent, + { + deltaX: intent.deltaX, + deltaY: intent.deltaY, + ...(intent.selector === undefined ? {} : { selector: intent.selector }), + }, + options, + ); + } + + previewType(intent: PreviewTypeIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.type", + intent, + { text: intent.text, ...(intent.selector === undefined ? {} : { selector: intent.selector }) }, + options, + ); + } + + previewPress(intent: PreviewPressIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand("preview.press", intent, { key: intent.key }, options); + } + + previewFill(intent: PreviewFillIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.fill", + intent, + { text: intent.text, ...(intent.selector === undefined ? {} : { selector: intent.selector }) }, + options, + ); + } + + previewSelect(intent: PreviewSelectIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.select", + intent, + { selector: intent.selector, value: intent.value }, + options, + ); + } + + previewUpload(intent: PreviewUploadIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.upload", + intent, + { selector: intent.selector, path: intent.path }, + options, + ); + } + + previewPolicyCheck( + intent: PreviewPolicyCheckIntent, + options: CommandOptions = {}, + ): Promise { + return this.sendCommand( + { + hostId: intent.hostId, + sessionId: intent.sessionId, + command: "preview.policy.check", + args: { + action: intent.action, + ...(intent.previewId === undefined ? {} : { previewId: intent.previewId }), + ...(intent.url === undefined ? {} : { url: intent.url }), + ...(intent.authorityId === undefined ? {} : { authorityId: intent.authorityId }), + }, + }, + options, + ); + } + + previewLeaseAcquire( + identity: PreviewIdentity, + ttlMs?: number, + options: CommandOptions = {}, + ): Promise { + return this.previewTargetCommand( + "preview.lease.acquire", + identity, + { ...(ttlMs === undefined ? {} : { ttlMs }) }, + options, + ); + } + + previewLeaseRenew( + identity: PreviewLeaseIdentity, + ttlMs?: number, + options: CommandOptions = {}, + ): Promise { + if (identity.leaseId === undefined) + return Promise.reject(this.error("protocol", "preview lease renewal requires leaseId", false)); + return this.previewTargetCommand( + "preview.lease.renew", + identity, + { leaseId: identity.leaseId, ...(ttlMs === undefined ? {} : { ttlMs }) }, + options, + ); + } + + previewLeaseRelease( + identity: PreviewLeaseIdentity, + options: CommandOptions = {}, + ): Promise { + if (identity.leaseId === undefined) + return Promise.reject(this.error("protocol", "preview lease release requires leaseId", false)); + return this.previewTargetCommand( + "preview.lease.release", + identity, + { leaseId: identity.leaseId }, + options, + ); + } + + previewHandoff(intent: PreviewHandoffIntent, options: CommandOptions = {}): Promise { + return this.previewTargetCommand( + "preview.handoff", + intent, + { + message: intent.message, + ...(intent.mode === undefined ? {} : { mode: intent.mode }), + ...(intent.selector === undefined ? {} : { selector: intent.selector }), + ...(intent.urlSubstring === undefined ? {} : { urlSubstring: intent.urlSubstring }), + ...(intent.text === undefined ? {} : { text: intent.text }), + ...(intent.timeoutMs === undefined ? {} : { timeoutMs: intent.timeoutMs }), + }, + options, + ); } confirm(intent: ConfirmIntent, options: CommandOptions = {}): Promise { - if (this.stateValue !== "ready") return Promise.reject(this.error("invalid_state", "client is not ready")); + if (this.stateValue !== "ready") + return Promise.reject(this.error("invalid_state", "client is not ready")); const request = this.ids.next("request"); const message = { kind: "confirm", @@ -335,7 +740,8 @@ export class OmpClient { } pairStart(intent: PairStartIntent, options: CommandOptions = {}): Promise { - if (this.stateValue !== "pairing") return Promise.reject(this.error("invalid_state", "pairing is not required")); + if (this.stateValue !== "pairing") + return Promise.reject(this.error("invalid_state", "pairing is not required")); const request = this.ids.next("request"); const message = { kind: "pair-start", @@ -352,13 +758,81 @@ export class OmpClient { }); } + private previewTargetCommand( + command: string, + identity: PreviewCommandTarget, + args: Record, + options: CommandOptions, + ): Promise { + return this.sendCommand( + { + hostId: identity.hostId, + sessionId: identity.sessionId, + command, + args: { + previewId: identity.previewId, + ...(identity.leaseId === undefined ? {} : { leaseId: identity.leaseId }), + ...args, + }, + }, + options, + ); + } + + private async readPreviewCapture( + identity: PreviewCommandTarget, + captureId: string, + offset: number, + ): Promise { + const response = await this.previewCaptureRead(identity, captureId, offset); + const result = response.result; + if (!response.ok || result === null || typeof result !== "object" || Array.isArray(result)) + throw this.error("protocol", "invalid preview capture response", false, { + command: "preview.capture.read", + }); + const value = result as Record; + if ( + typeof value.previewId !== "string" || + typeof value.captureId !== "string" || + typeof value.size !== "number" || + typeof value.offset !== "number" || + typeof value.nextOffset !== "number" || + typeof value.complete !== "boolean" || + typeof value.content !== "string" + ) { + throw this.error("protocol", "invalid preview capture response", false, { + command: "preview.capture.read", + }); + } + return Object.freeze({ + previewId: value.previewId, + captureId: value.captureId, + size: value.size, + offset: value.offset, + nextOffset: value.nextOffset, + complete: value.complete, + content: value.content, + }); + } + private sendCommand(intent: CommandIntent, options: CommandOptions): Promise { - if (this.stateValue !== "ready") return Promise.reject(this.error("invalid_state", "client is not ready")); + if (this.stateValue !== "ready") + return Promise.reject(this.error("invalid_state", "client is not ready")); const descriptor = this.protocol.commandDescriptor(intent.command); if (descriptor === undefined) return Promise.reject(this.error("protocol", "unknown command")); const capability = this.protocol.requiredCapability(intent.command); if (capability !== undefined && !this.granted.has(capability)) { - return Promise.reject(this.error("capability", "command capability was not granted", false, { capability })); + return Promise.reject( + this.error( + "capability", + `${intent.command} requires negotiated capability ${capability}`, + false, + { + capability, + command: intent.command, + }, + ), + ); } const request = this.ids.next("request"); const command = this.ids.next("command"); @@ -374,7 +848,12 @@ export class OmpClient { return result; }); } - private sendTerminalFrame(message: Extract): void { + private sendTerminalFrame( + message: Extract< + OmpClientMessage, + { kind: "terminal-input" | "terminal-resize" | "terminal-close" } + >, + ): void { if (this.stateValue !== "ready") throw this.error("invalid_state", "client is not ready"); const encoded = encodeOutgoingMessage(this.protocol, message); if (encoded === undefined) throw this.error("protocol", "invalid terminal intent"); @@ -393,18 +872,26 @@ export class OmpClient { kind: Pending["kind"], intent?: CommandIntent, ): Promise { - return this.pendingRequests.begin(message, requestText, options, kind, intent, (pendingMessage, pending) => { - const encoded = encodeOutgoingMessage(this.protocol, pendingMessage); - if (encoded === undefined) throw this.error("protocol", "outbound message could not be encoded"); - try { - pending.handedToTransport = true; - this.connection.send(encoded); - } catch (error) { - pending.handedToTransport = false; - if (error instanceof OmpClientError) throw error; - throw this.error("transport", "transport send failed", true); - } - }); + return this.pendingRequests.begin( + message, + requestText, + options, + kind, + intent, + (pendingMessage, pending) => { + const encoded = encodeOutgoingMessage(this.protocol, pendingMessage); + if (encoded === undefined) + throw this.error("protocol", "outbound message could not be encoded"); + try { + pending.handedToTransport = true; + this.connection.send(encoded); + } catch (error) { + pending.handedToTransport = false; + if (error instanceof OmpClientError) throw error; + throw this.error("transport", "transport send failed", true); + } + }, + ); } private handleConnected(_generation: number): void { @@ -414,7 +901,10 @@ export class OmpClient { this.transition("handshaking"); this.sendHello(); if (this.stateValue === "handshaking") { - this.handshakeTimer = this.schedule(() => this.protocolFailure("handshake timed out"), this.options.handshakeTimeoutMs ?? 10_000); + this.handshakeTimer = this.schedule( + () => this.protocolFailure("handshake timed out"), + this.options.handshakeTimeoutMs ?? 10_000, + ); } } private sendHello(): void { @@ -435,7 +925,9 @@ export class OmpClient { (error) => this.handleTransportError(error), ); } - private clearInbound(): void { this.inboundQueue.clear(); } + private clearInbound(): void { + this.inboundQueue.clear(); + } private handleRaw(raw: string | Uint8Array, generation: number): void | Promise { if (generation !== this.generation || this.closedByUser) return; @@ -474,7 +966,9 @@ export class OmpClient { this.reconnectHealth.beginWelcome(this.generation, this.attached.keys()); for (const feature of this.options.requiredFeatures ?? []) { if (!frame.grantedFeatures.includes(feature)) { - this.fatal(this.error("capability", "required feature was not granted", false, { feature })); + this.fatal( + this.error("capability", "required feature was not granted", false, { feature }), + ); return; } } @@ -491,13 +985,12 @@ export class OmpClient { this.desyncedSessions.delete(currentKey); this.epochValue = frame.cursor.epoch; this.cursorValue = frame.cursor; - this.cursorJournal.remember({ hostId: String(frame.hostId), sessionId: String(frame.sessionId), cursor: frame.cursor }); - this.reconnectHealth.acceptReplayProgress( - this.generation, - currentKey, - frame.cursor, - true, - ); + this.cursorJournal.remember({ + hostId: String(frame.hostId), + sessionId: String(frame.sessionId), + cursor: frame.cursor, + }); + this.reconnectHealth.acceptReplayProgress(this.generation, currentKey, frame.cursor, true); this.publish(event); } @@ -509,7 +1002,11 @@ export class OmpClient { if (this.desyncedSessions.has(currentKey)) return false; this.cursorValue = frame.cursor; this.epochValue = frame.cursor.epoch; - this.cursorJournal.remember({ hostId: String(frame.hostId), sessionId: String(frame.sessionId), cursor: frame.cursor }); + this.cursorJournal.remember({ + hostId: String(frame.hostId), + sessionId: String(frame.sessionId), + cursor: frame.cursor, + }); this.reconnectHealth.acceptReplayProgress(this.generation, currentKey, frame.cursor, false); return true; } @@ -519,12 +1016,19 @@ export class OmpClient { } if (frame.cursor.seq <= previous.seq) return false; if (frame.cursor.seq !== previous.seq + 1 || this.desyncedSessions.has(currentKey)) { - this.markDesynced(currentKey, "durable cursor is not contiguous", { expectedSeq: previous.seq + 1, receivedSeq: frame.cursor.seq }); + this.markDesynced(currentKey, "durable cursor is not contiguous", { + expectedSeq: previous.seq + 1, + receivedSeq: frame.cursor.seq, + }); return false; } this.cursorValue = frame.cursor; this.epochValue = frame.cursor.epoch; - this.cursorJournal.remember({ hostId: String(frame.hostId), sessionId: String(frame.sessionId), cursor: frame.cursor }); + this.cursorJournal.remember({ + hostId: String(frame.hostId), + sessionId: String(frame.sessionId), + cursor: frame.cursor, + }); this.reconnectHealth.acceptReplayProgress(this.generation, currentKey, frame.cursor, false); return true; } @@ -552,7 +1056,10 @@ export class OmpClient { }); } - private async handlePairOk(event: OmpServerEventOf<"pair.ok">, generation: number): Promise { + private async handlePairOk( + event: OmpServerEventOf<"pair.ok">, + generation: number, + ): Promise { const frame = event.payload; if (generation !== this.generation || this.closedByUser) return; const pending = this.pendingRequests.entries.get(String(frame.requestId)); @@ -561,15 +1068,25 @@ export class OmpClient { return; } const requested = new Set(pending.message.requestedCapabilities); - if (frame.deviceId !== pending.message.deviceId || frame.deviceName !== pending.message.deviceName || frame.platform !== pending.message.platform || frame.requestedCapabilities.some((cap) => !requested.has(cap)) || frame.grantedCapabilities.some((cap) => !requested.has(cap)) || !Number.isFinite(Date.parse(frame.expiresAt)) || Date.parse(frame.expiresAt) <= this.clock.now()) { + if ( + frame.deviceId !== pending.message.deviceId || + frame.deviceName !== pending.message.deviceName || + frame.platform !== pending.message.platform || + frame.requestedCapabilities.some((cap) => !requested.has(cap)) || + frame.grantedCapabilities.some((cap) => !requested.has(cap)) || + !Number.isFinite(Date.parse(frame.expiresAt)) || + Date.parse(frame.expiresAt) <= this.clock.now() + ) { this.fatal(this.error("auth", "pairing response validation failed")); return; } try { - if (this.options.privilegedPairResult === undefined) throw new Error("pairing sink unavailable"); + if (this.options.privilegedPairResult === undefined) + throw new Error("pairing sink unavailable"); await this.options.privilegedPairResult(frame); } catch { - if (generation === this.generation && !this.closedByUser) this.fatal(this.error("auth", "pairing credential could not be stored")); + if (generation === this.generation && !this.closedByUser) + this.fatal(this.error("auth", "pairing credential could not be stored")); return; } if (generation !== this.generation || this.closedByUser) return; @@ -587,7 +1104,12 @@ export class OmpClient { const frame = event.payload; const id = String(frame.requestId); const pending = this.pendingRequests.entries.get(id); - if (pending?.kind === "pair") this.pendingRequests.settle(id, undefined, this.error("auth", "pairing request failed", false, { code: frame.code })); + if (pending?.kind === "pair") + this.pendingRequests.settle( + id, + undefined, + this.error("auth", "pairing request failed", false, { code: frame.code }), + ); } private handleDisconnect(code?: number, reason?: string): void { @@ -624,9 +1146,22 @@ export class OmpClient { this.connection.disconnect(); for (const [id, pending] of this.pendingRequests.entries) { if (pending.handedToTransport) { - this.pendingRequests.settle(id, undefined, this.error("outcome_unknown", "request outcome is unknown; inspect server state before retrying", true, pending.commandId === undefined ? undefined : { commandId: pending.commandId })); + this.pendingRequests.settle( + id, + undefined, + this.error( + "outcome_unknown", + "request outcome is unknown; inspect server state before retrying", + true, + pending.commandId === undefined ? undefined : { commandId: pending.commandId }, + ), + ); } else { - this.pendingRequests.settle(id, undefined, this.error("transport", "transport disconnected before request was sent", true)); + this.pendingRequests.settle( + id, + undefined, + this.error("transport", "transport disconnected before request was sent", true), + ); } } this.scheduleReconnect(); @@ -642,15 +1177,64 @@ export class OmpClient { } private reattachSessions(): void { + const generation = this.generation; for (const record of this.attached.values()) { - const cursor = this.cursorJournal.records.get(`${String(record.hostId)}\u0000${String(record.sessionId)}`)?.cursor; + const cursor = this.cursorJournal.records.get( + `${String(record.hostId)}\u0000${String(record.sessionId)}`, + )?.cursor; this.sendCommand( - { hostId: String(record.hostId), sessionId: String(record.sessionId), command: "session.attach", args: cursor === undefined ? {} : { cursor } }, + { + hostId: String(record.hostId), + sessionId: String(record.sessionId), + command: "session.attach", + args: cursor === undefined ? {} : { cursor }, + }, { timeoutMs: this.options.commandTimeoutMs ?? 30_000 }, - ).catch(() => undefined); + ) + .then((response) => { + if (response.ok) this.requestPreviewState(record, generation); + else this.emitRecoveryFailure("session.attach"); + }) + .catch((error: unknown) => this.emitRecoveryError(error)); } } + private requestPreviewState( + record: { readonly hostId: HostId; readonly sessionId: SessionId }, + generation: number, + ): void { + if ( + generation !== this.generation || + this.stateValue !== "ready" || + !this.granted.has("preview.control") + ) + return; + if (this.previewStateGeneration !== generation) { + this.previewStateGeneration = generation; + this.previewStateSessions.clear(); + } + const key = sessionKey(String(record.hostId), String(record.sessionId)); + if (this.previewStateSessions.has(key)) return; + this.previewStateSessions.add(key); + void this.previewState(String(record.hostId), String(record.sessionId)) + .then((response) => { + if (!response.ok) this.emitRecoveryFailure("preview.state"); + }) + .catch((error: unknown) => this.emitRecoveryError(error)); + } + + private emitRecoveryFailure(command: string): void { + this.emitError(this.error("protocol", `${command} recovery request failed`, true, { command })); + } + + private emitRecoveryError(error: unknown): void { + this.emitError( + error instanceof OmpClientError + ? error + : this.error("transport", "preview recovery request failed", true), + ); + } + private startHeartbeat(): void { this.heartbeatNonce = undefined; this.connection.startHeartbeat( @@ -681,9 +1265,13 @@ export class OmpClient { this.reconnectHealth.acceptPong(this.generation); } - - private markDesynced(key: string, message: string, metadata?: Record): void { - if (!this.desyncedSessions.has(key)) this.emitError(this.error("desync", message, true, metadata)); + private markDesynced( + key: string, + message: string, + metadata?: Record, + ): void { + if (!this.desyncedSessions.has(key)) + this.emitError(this.error("desync", message, true, metadata)); this.desyncedSessions.add(key); } @@ -702,6 +1290,7 @@ export class OmpClient { this.pendingRequests.rejectAll(error); for (const waiter of this.connectWaiters.splice(0)) waiter.reject(error); this.connection.disconnect(); + this.previewCaptures.dispose(); if (this.stateValue !== "fatal" && this.stateValue !== "closed") this.transition("fatal"); } @@ -709,8 +1298,7 @@ export class OmpClient { if (this.lastInboundAt === undefined) return true; const configured = this.options.wakeStaleAfterMs; const fallback = - (this.options.heartbeat?.intervalMs ?? 15_000) + - (this.options.heartbeat?.timeoutMs ?? 5_000); + (this.options.heartbeat?.intervalMs ?? 15_000) + (this.options.heartbeat?.timeoutMs ?? 5_000); const staleAfter = configured !== undefined && Number.isFinite(configured) && configured > 0 ? configured @@ -719,22 +1307,54 @@ export class OmpClient { } private reviveRetryableTransportFailure(): void { - if (this.stateValue !== "fatal" || this.fatalError?.code !== "transport" || !this.fatalError.retryable) return; + if ( + this.stateValue !== "fatal" || + this.fatalError?.code !== "transport" || + !this.fatalError.retryable + ) + return; this.closedByUser = false; this.fatalError = undefined; this.transition("connecting"); this.connection.begin(); } - private error(code: ClientErrorCode, message: string, retryable = false, metadata?: Record): OmpClientError { - return new OmpClientError({ code, message, retryable, ...(metadata === undefined ? {} : { metadata: boundedMetadata(metadata) }) }); + private error( + code: ClientErrorCode, + message: string, + retryable = false, + metadata?: Record, + ): OmpClientError { + return new OmpClientError({ + code, + message, + retryable, + ...(metadata === undefined ? {} : { metadata: boundedMetadata(metadata) }), + }); } private publish(event: PublicOmpServerEvent): void { this.events.publish(event, this.projection); + if (this.projection === undefined) return; + const retained: PreviewCommandTarget[] = []; + for (const session of this.projection.snapshot.sessions.values()) + for (const preview of session.previews.values()) { + const identity = { + hostId: preview.hostId, + sessionId: preview.sessionId, + previewId: preview.previewId, + }; + retained.push(identity); + this.previewCaptures.replace(identity, preview.capture); + } + this.previewCaptures.retain(retained); } - private emitError(error: OmpClientError): void { this.events.emitError(error); } - private emitState(): void { this.events.emitState(this.snapshot()); } + private emitError(error: OmpClientError): void { + this.events.emitError(error); + } + private emitState(): void { + this.events.emitState(this.snapshot()); + } private transition(next: OmpClientState): void { if (!isLegalClientTransition(this.stateValue, next)) return; diff --git a/packages/client/src/preview.ts b/packages/client/src/preview.ts new file mode 100644 index 0000000..3997275 --- /dev/null +++ b/packages/client/src/preview.ts @@ -0,0 +1,490 @@ +export const PREVIEW_CAPTURE_MAX_BYTES = 8 * 1024 * 1024; +export const PREVIEW_CAPTURE_MAX_PIXELS = 16 * 1024 * 1024; +export const PREVIEW_CAPTURE_READ_CHUNK_BYTES = 256 * 1024; + +export interface PreviewIdentity { + readonly hostId: string; + readonly sessionId: string; + readonly previewId: string; +} + +export interface PreviewCaptureMetadata { + readonly captureId: string; + readonly mimeType: "image/png" | "image/jpeg" | "image/webp"; + readonly size: number; + readonly width: number; + readonly height: number; + readonly capturedAt: number; + readonly sha256: string; +} + +export interface PreviewCaptureReadResult { + readonly previewId: string; + readonly captureId: string; + readonly size: number; + readonly offset: number; + readonly nextOffset: number; + readonly complete: boolean; + readonly content: string; +} + +export interface PreviewCaptureResourceOptions { + readonly read: ( + identity: PreviewIdentity, + captureId: string, + offset: number, + ) => Promise; + readonly createObjectURL?: (blob: Blob) => string; + readonly revokeObjectURL?: (url: string) => void; + readonly sha256?: (bytes: Uint8Array) => Promise; +} + +interface CaptureResource { + readonly capture: PreviewCaptureMetadata; + blob?: Blob; + url?: string; + loading?: Promise | undefined; +} + +function positiveInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value <= 0) + throw new Error(`invalid preview capture ${name}`); +} + +function identityKey(identity: PreviewIdentity): string { + return `${identity.hostId}\u0000${identity.sessionId}\u0000${identity.previewId}`; +} + +export function previewKey(identity: PreviewIdentity): string { + return identityKey(identity); +} + +function assertMetadata(capture: PreviewCaptureMetadata): void { + positiveInteger(capture.size, "size"); + positiveInteger(capture.width, "width"); + positiveInteger(capture.height, "height"); + if (capture.size > PREVIEW_CAPTURE_MAX_BYTES) + throw new Error("preview capture exceeds byte limit"); + if ( + capture.width > PREVIEW_CAPTURE_MAX_PIXELS || + capture.height > PREVIEW_CAPTURE_MAX_PIXELS || + capture.width * capture.height > PREVIEW_CAPTURE_MAX_PIXELS + ) + throw new Error("preview capture exceeds pixel limit"); + if (!/^[a-f0-9]{64}$/u.test(capture.sha256)) throw new Error("invalid preview capture hash"); +} + +function decodeBase64(value: string): Uint8Array { + if (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) + throw new Error("invalid preview capture base64"); + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + if ( + (padding === 2 && (alphabet.indexOf(value[value.length - 3]!) & 0x0f) !== 0) || + (padding === 1 && (alphabet.indexOf(value[value.length - 2]!) & 0x03) !== 0) + ) { + throw new Error("non-canonical preview capture base64"); + } + if (typeof atob === "function") { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } + return new Uint8Array(Buffer.from(value, "base64")); +} + +function parsedDimensions( + bytes: Uint8Array, + mimeType: PreviewCaptureMetadata["mimeType"], +): readonly [number, number] | undefined { + const read32 = (offset: number): number => + ((bytes[offset]! << 24) | + (bytes[offset + 1]! << 16) | + (bytes[offset + 2]! << 8) | + bytes[offset + 3]!) >>> + 0; + if (mimeType === "image/png") { + if ( + bytes.length < 24 || + String.fromCharCode(...bytes.slice(1, 4)) !== "PNG" || + String.fromCharCode(...bytes.slice(12, 16)) !== "IHDR" + ) + throw new Error("preview capture bytes are not PNG"); + return [read32(16), read32(20)]; + } + if (mimeType === "image/jpeg") { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) + throw new Error("preview capture bytes are not JPEG"); + for (let offset = 2; offset + 9 < bytes.length; ) { + if (bytes[offset] !== 0xff) throw new Error("invalid JPEG marker"); + while (bytes[offset] === 0xff) offset += 1; + const marker = bytes[offset++]!; + if (marker === 0xd9 || marker === 0xda) break; + if (offset + 1 >= bytes.length) break; + const length = (bytes[offset]! << 8) | bytes[offset + 1]!; + if (length < 2 || offset + length > bytes.length) throw new Error("invalid JPEG segment"); + if ( + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) + ) + return [ + (bytes[offset + 5]! << 8) | bytes[offset + 6]!, + (bytes[offset + 3]! << 8) | bytes[offset + 4]!, + ]; + offset += length; + } + throw new Error("JPEG preview capture has no dimensions"); + } + if ( + bytes.length < 16 || + String.fromCharCode(...bytes.slice(0, 4)) !== "RIFF" || + String.fromCharCode(...bytes.slice(8, 12)) !== "WEBP" + ) + throw new Error("preview capture bytes are not WebP"); + const chunk = String.fromCharCode(...bytes.slice(12, 16)); + if (chunk === "VP8X" && bytes.length >= 30) + return [ + 1 + bytes[24]! + (bytes[25]! << 8) + (bytes[26]! << 16), + 1 + bytes[27]! + (bytes[28]! << 8) + (bytes[29]! << 16), + ]; + if ( + chunk === "VP8 " && + bytes.length >= 30 && + bytes[23] === 0x9d && + bytes[24] === 0x01 && + bytes[25] === 0x2a + ) + return [((bytes[27]! << 8) | bytes[26]!) & 0x3fff, ((bytes[29]! << 8) | bytes[28]!) & 0x3fff]; + if (chunk === "VP8L" && bytes.length >= 25 && bytes[20] === 0x2f) { + const bits = bytes[21]! | (bytes[22]! << 8) | (bytes[23]! << 16) | (bytes[24]! << 24); + return [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1]; + } + throw new Error("unsupported WebP preview capture"); +} + +function assertRaster(bytes: Uint8Array, capture: PreviewCaptureMetadata): void { + if (bytes.byteLength !== capture.size) throw new Error("preview capture size mismatch"); + const dimensions = parsedDimensions(bytes, capture.mimeType); + if (dimensions === undefined) throw new Error("preview capture dimensions unavailable"); + const [width, height] = dimensions; + positiveInteger(width, "width"); + positiveInteger(height, "height"); + if (width * height > PREVIEW_CAPTURE_MAX_PIXELS) + throw new Error("preview capture raster exceeds pixel limit"); + if (width !== capture.width || height !== capture.height) + throw new Error("preview capture dimensions mismatch"); +} + +async function defaultSha256(bytes: Uint8Array): Promise { + const input = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const digest = await globalThis.crypto.subtle.digest("SHA-256", input); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** Owns decoded preview pixels and their object URLs; projections retain metadata only. */ +export class PreviewCaptureResource { + private readonly resources = new Map(); + private readonly createObjectURL: (blob: Blob) => string; + private readonly revokeObjectURL: (url: string) => void; + private readonly sha256: (bytes: Uint8Array) => Promise; + private readonly options: PreviewCaptureResourceOptions; + + constructor(options: PreviewCaptureResourceOptions) { + this.options = options; + this.createObjectURL = options.createObjectURL ?? ((blob) => URL.createObjectURL(blob)); + this.revokeObjectURL = options.revokeObjectURL ?? ((url) => URL.revokeObjectURL(url)); + this.sha256 = options.sha256 ?? defaultSha256; + } + + async objectUrl(identity: PreviewIdentity, capture: PreviewCaptureMetadata): Promise { + assertMetadata(capture); + const key = identityKey(identity); + let resource = this.resources.get(key); + if ( + resource === undefined || + resource.capture.captureId !== capture.captureId || + resource.capture.sha256 !== capture.sha256 + ) { + this.release(identity); + resource = { capture }; + this.resources.set(key, resource); + } + if (resource.url !== undefined) return resource.url; + try { + const blob = await this.loadResource(key, resource, identity); + if (this.resources.get(key) !== resource) + throw new Error("preview capture was replaced while loading"); + if (resource.url === undefined) resource.url = this.createObjectURL(blob); + return resource.url; + } catch (error) { + if (this.resources.get(key) === resource && resource.url === undefined) + this.resources.delete(key); + throw error; + } + } + + replace(identity: PreviewIdentity, capture: PreviewCaptureMetadata | undefined): void { + const current = this.resources.get(identityKey(identity)); + if ( + current !== undefined && + (capture === undefined || + current.capture.captureId !== capture.captureId || + current.capture.sha256 !== capture.sha256) + ) + this.release(identity); + } + + release(identity: PreviewIdentity): void { + const resource = this.resources.get(identityKey(identity)); + if (resource?.url !== undefined) this.revokeObjectURL(resource.url); + this.resources.delete(identityKey(identity)); + } + + retain(identities: Iterable): void { + const retained = new Set(); + for (const identity of identities) retained.add(identityKey(identity)); + for (const [key, resource] of this.resources) + if (!retained.has(key)) { + if (resource.url !== undefined) this.revokeObjectURL(resource.url); + this.resources.delete(key); + } + } + + dispose(): void { + for (const resource of this.resources.values()) + if (resource.url !== undefined) this.revokeObjectURL(resource.url); + this.resources.clear(); + } + + private loadResource( + key: string, + resource: CaptureResource, + identity: PreviewIdentity, + ): Promise { + if (resource.blob !== undefined) return Promise.resolve(resource.blob); + if (resource.loading !== undefined) return resource.loading; + const loading = this.load(identity, resource.capture) + .then((blob) => { + if (this.resources.get(key) !== resource) + throw new Error("preview capture was replaced while loading"); + resource.blob = blob; + return blob; + }) + .finally(() => { + if (this.resources.get(key) === resource) resource.loading = undefined; + }); + resource.loading = loading; + return loading; + } + + private async load(identity: PreviewIdentity, capture: PreviewCaptureMetadata): Promise { + const bytes = new Uint8Array(capture.size); + let offset = 0; + while (offset < capture.size) { + const chunk = await this.options.read(identity, capture.captureId, offset); + if ( + chunk.previewId !== identity.previewId || + chunk.captureId !== capture.captureId || + chunk.size !== capture.size || + chunk.offset !== offset + ) + throw new Error("preview capture chunk identity or offset mismatch"); + if ( + !Number.isSafeInteger(chunk.nextOffset) || + chunk.nextOffset <= offset || + chunk.nextOffset > capture.size || + chunk.nextOffset - offset > PREVIEW_CAPTURE_READ_CHUNK_BYTES || + chunk.complete !== (chunk.nextOffset === capture.size) + ) + throw new Error("preview capture chunk bounds mismatch"); + const content = decodeBase64(chunk.content); + if (content.byteLength !== chunk.nextOffset - offset) + throw new Error("preview capture chunk size mismatch"); + bytes.set(content, offset); + offset = chunk.nextOffset; + } + const hash = await this.sha256(bytes); + if (hash !== capture.sha256) throw new Error("preview capture hash mismatch"); + assertRaster(bytes, capture); + return new Blob([bytes], { type: capture.mimeType }); + } +} + +export interface PreviewLeaseIdentity extends PreviewIdentity { + readonly leaseId?: string; +} + +export interface PreviewLeaseManagerClient { + previewLeaseAcquire(identity: PreviewIdentity, ttlMs?: number): Promise; + previewLeaseRenew(identity: PreviewLeaseIdentity, ttlMs?: number): Promise; + previewLeaseRelease(identity: PreviewLeaseIdentity): Promise; +} + +export interface PreviewLeaseManagerOptions { + readonly now?: () => number; + readonly defaultTtlMs?: number; +} + +interface PreviewLease { + readonly identity: PreviewIdentity; + readonly leaseId: string; + readonly expiresAt: number; + readonly ttlMs: number; +} + +function validLeaseResult( + result: unknown, + identity: PreviewIdentity, +): { readonly leaseId: string; readonly expiresAt: number } | undefined { + if ( + result === null || + typeof result !== "object" || + Array.isArray(result) || + !("previewId" in result) || + !("leaseId" in result) || + !("expiresAt" in result) + ) + return undefined; + const { previewId, leaseId, expiresAt } = result; + if ( + previewId !== identity.previewId || + typeof leaseId !== "string" || + leaseId.length === 0 || + leaseId.length > 256 || + typeof expiresAt !== "number" || + !Number.isSafeInteger(expiresAt) || + expiresAt <= 0 + ) + return undefined; + return { leaseId, expiresAt }; +} + +function leaseResponse(value: unknown): unknown { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + !("ok" in value) || + value.ok !== true || + !("result" in value) + ) + return undefined; + return value.result; +} + +/** + * Owns cooperative preview leases, independently of capture byte/object-URL + * resources. A lease is keyed by the complete preview identity and is never + * supplied to a different preview. + */ +export class PreviewLeaseManager { + private readonly leases = new Map(); + private readonly now: () => number; + private readonly defaultTtlMs: number; + private readonly client: PreviewLeaseManagerClient; + + constructor(client: PreviewLeaseManagerClient, options: PreviewLeaseManagerOptions = {}) { + this.client = client; + this.now = options.now ?? Date.now; + this.defaultTtlMs = + options.defaultTtlMs !== undefined && + Number.isSafeInteger(options.defaultTtlMs) && + options.defaultTtlMs > 0 + ? options.defaultTtlMs + : 30_000; + } + + /** Acquire only when a usable lease is absent; renew once half its TTL has elapsed. */ + async ensure(identity: PreviewIdentity, ttlMs = this.defaultTtlMs): Promise { + const key = identityKey(identity); + const previous = this.leases.get(key); + if (previous !== undefined && this.now() < previous.expiresAt) { + if (this.now() < previous.expiresAt - Math.floor(previous.ttlMs / 2)) + return previous.leaseId; + return this.renew(identity, ttlMs); + } + this.leases.delete(key); + const response = await this.client.previewLeaseAcquire(identity, ttlMs); + const lease = validLeaseResult(leaseResponse(response), identity); + if (lease === undefined) throw new Error("invalid preview lease acquire response"); + this.leases.set(key, Object.freeze({ identity: { ...identity }, ...lease, ttlMs })); + return lease.leaseId; + } + + async renew(identity: PreviewIdentity, ttlMs = this.defaultTtlMs): Promise { + const key = identityKey(identity); + const previous = this.leases.get(key); + if (previous === undefined) return this.ensure(identity, ttlMs); + try { + const response = await this.client.previewLeaseRenew( + { ...identity, leaseId: previous.leaseId }, + ttlMs, + ); + const lease = validLeaseResult(leaseResponse(response), identity); + if (lease === undefined) throw new Error("invalid preview lease renew response"); + this.leases.set(key, Object.freeze({ identity: { ...identity }, ...lease, ttlMs })); + return lease.leaseId; + } catch (error) { + this.leases.delete(key); + throw error; + } + } + + /** + * Runs a mutation with a matching lease. Transport and ownership failures + * invalidate that lease so no later mutation can accidentally reuse it. + */ + async mutate( + identity: PreviewIdentity, + operation: (leaseId: string) => Promise, + ttlMs = this.defaultTtlMs, + ): Promise { + const leaseId = await this.ensure(identity, ttlMs); + try { + return await operation(leaseId); + } catch (error) { + let code = ""; + if (error !== null && typeof error === "object" && "code" in error) + code = String(error.code); + if ( + /transport|ownership|owner|lease|closed/i.test(code) || + ["FORBIDDEN", "CONFLICT", "NOT_FOUND"].includes(code) + ) + this.invalidate(identity); + throw error; + } + } + + /** Long handoffs renew before the lease reaches its half-TTL threshold. */ + async beforeHandoff(identity: PreviewIdentity, timeoutMs?: number): Promise { + const ttlMs = + timeoutMs !== undefined && Number.isSafeInteger(timeoutMs) && timeoutMs > this.defaultTtlMs / 2 + ? Math.min(timeoutMs * 2, 300_000) + : this.defaultTtlMs; + return this.ensure(identity, ttlMs); + } + + invalidate(identity: PreviewIdentity): void { + this.leases.delete(identityKey(identity)); + } + + async release(identity: PreviewIdentity): Promise { + const key = identityKey(identity); + const lease = this.leases.get(key); + this.leases.delete(key); + if (lease === undefined) return; + try { + await this.client.previewLeaseRelease({ ...identity, leaseId: lease.leaseId }); + } catch { + // Teardown must not retain or resurrect a lease after a transport loss. + } + } + + async releaseAll(): Promise { + await Promise.all([...this.leases.values()].map((lease) => this.release(lease.identity))); + } +} diff --git a/packages/client/src/projection-cache.ts b/packages/client/src/projection-cache.ts index 5aa2702..7dac626 100644 --- a/packages/client/src/projection-cache.ts +++ b/packages/client/src/projection-cache.ts @@ -1,8 +1,4 @@ -import type { - Cursor, - DurableEntry, - SessionRef, -} from "@t4-code/protocol"; +import type { Cursor, DurableEntry, SessionRef } from "@t4-code/protocol"; import { MAX_PROJECTION_CACHE_BYTES } from "@t4-code/protocol/desktop-ipc"; import { MAX_INDEXED_SESSION_REFS } from "./projection.ts"; import type { @@ -13,12 +9,16 @@ import type { SessionIndexMetadata, SessionProjection, TerminalProjection, + PreviewProjection, + PreviewAuthorityProjection, + PreviewEventProjection, } from "./projection.ts"; import { ImmutableSet } from "./immutable-set.ts"; import { ImmutableMap } from "./immutable-map.ts"; import { retainedJsonBytes } from "./transcript-retention.ts"; +import { previewKey, type PreviewCaptureMetadata } from "./preview.ts"; -export const PROJECTION_CACHE_VERSION = 1 as const; +export const PROJECTION_CACHE_VERSION = 2 as const; export { MAX_PROJECTION_CACHE_BYTES }; export const MAX_PROJECTION_CACHE_SESSIONS = 8; const MAX_PROJECTION_CACHE_TRANSCRIPT_BYTES = Math.floor(MAX_PROJECTION_CACHE_BYTES * 0.75); @@ -35,7 +35,7 @@ export interface ProjectionCacheStore { } export interface ProjectionCacheEnvelope { readonly kind: "t4-code-projection"; - readonly version: 1; + readonly version: 1 | typeof PROJECTION_CACHE_VERSION; readonly savedAt: number; readonly data: ProjectionCacheData; } @@ -77,6 +77,25 @@ interface SessionProjectionData { readonly freshness: ProjectionFreshness; readonly gap?: unknown; readonly historyTruncated?: boolean; + readonly previews?: Array<[string, PreviewProjectionData]>; + readonly previewEvents?: readonly PreviewEventProjection[]; +} +interface PreviewProjectionData { + readonly hostId: string; + readonly sessionId: string; + readonly previewId: string; + readonly state?: PreviewProjection["state"]; + readonly url?: string; + readonly revision: string; + readonly cursor: Cursor; + readonly title?: string; + readonly canGoBack?: boolean; + readonly canGoForward?: boolean; + readonly viewport?: { width: number; height: number; deviceScaleFactor?: number }; + readonly capture?: PreviewCaptureMetadata; + readonly authority?: PreviewAuthorityProjection; + readonly availableActions?: PreviewProjection["availableActions"]; + readonly error?: { code: string; message: string }; } interface AgentTranscriptProjectionData { readonly entries: readonly DurableEntry[]; @@ -131,6 +150,45 @@ function cachedAgentTranscripts( ]); } +function cachedPreviewUrl(url: string | undefined): string | undefined { + if (url === undefined) return undefined; + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return undefined; + } +} +function cachedPreviews( + previews: ReadonlyMap, +): Array<[string, PreviewProjectionData]> { + return [...previews.values()].slice(-32).map((preview) => { + const url = cachedPreviewUrl(preview.url); + return [ + previewKey(preview), + { + hostId: preview.hostId, + sessionId: preview.sessionId, + previewId: preview.previewId, + ...(preview.state === undefined ? {} : { state: preview.state }), + ...(url === undefined ? {} : { url }), + revision: preview.revision, + cursor: { ...preview.cursor }, + ...(preview.title === undefined ? {} : { title: preview.title }), + ...(preview.canGoBack === undefined ? {} : { canGoBack: preview.canGoBack }), + ...(preview.canGoForward === undefined ? {} : { canGoForward: preview.canGoForward }), + ...(preview.viewport === undefined ? {} : { viewport: { ...preview.viewport } }), + ...(preview.capture === undefined ? {} : { capture: { ...preview.capture } }), + ...(preview.authority === undefined ? {} : { authority: { ...preview.authority } }), + ...(preview.availableActions === undefined + ? {} + : { availableActions: [...preview.availableActions] }), + ...(preview.error === undefined ? {} : { error: { ...preview.error } }), + } satisfies PreviewProjectionData, + ]; + }); +} + function cachedEntries( entries: readonly DurableEntry[], maxBytes: number, @@ -190,6 +248,10 @@ export function encodeProjectionCache(snapshot: ProjectionSnapshot, savedAt = Da // that the new connection can never consume. confirmations: [], results: arrayFromMap(value.results), + previews: cachedPreviews(value.previews), + previewEvents: value.previewEvents + .slice(-128) + .map((event) => safeJson(event) as PreviewEventProjection), ...(value.revision === undefined ? {} : { revision: value.revision }), ...(value.cursor === undefined ? {} : { cursor: value.cursor }), ...(value.epoch === undefined ? {} : { epoch: value.epoch }), @@ -270,9 +332,250 @@ function asMap( } return new ImmutableMap(map); } + +const PREVIEW_ACTIONS = [ + "activate", + "navigate", + "back", + "forward", + "reload", + "close", + "capture", + "click", + "fill", + "type", + "press", + "scroll", + "select", + "upload", + "handoff", +] as const; + +function restoredPreviewAuthority(value: unknown): PreviewAuthorityProjection | undefined { + if (value === undefined) return undefined; + if ( + !isRecord(value) || + typeof value.id !== "string" || + value.id.length === 0 || + value.id.length > 128 || + typeof value.label !== "string" || + value.label.length > 256 || + (value.kind !== "isolated-session" && value.kind !== "authenticated-profile") || + typeof value.requiresExplicitOptIn !== "boolean" + ) + throw new Error("invalid preview authority cache"); + return Object.freeze({ + id: value.id, + label: value.label, + kind: value.kind, + requiresExplicitOptIn: value.requiresExplicitOptIn, + }); +} + +function restoredPreviewActions(value: unknown): PreviewProjection["availableActions"] { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length > PREVIEW_ACTIONS.length) + throw new Error("invalid preview actions cache"); + if ( + value.some((action) => typeof action !== "string" || !PREVIEW_ACTIONS.includes(action as never)) || + new Set(value).size !== value.length + ) + throw new Error("invalid preview actions cache"); + return Object.freeze([...value]) as PreviewProjection["availableActions"]; +} + +function restoredPreviewEvents(value: unknown): readonly PreviewEventProjection[] { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value)) throw new Error("invalid preview event cache"); + const events: PreviewEventProjection[] = []; + for (const raw of value.slice(-128)) { + if ( + !isRecord(raw) || + !["launch", "navigation", "capture", "error"].includes(String(raw.type)) || + typeof raw.previewId !== "string" || + raw.previewId.length === 0 + ) + throw new Error("invalid preview event cache"); + const cursor = raw.cursor; + const url = raw.url; + const timestamp = raw.timestamp; + const captureId = raw.captureId; + const errorCode = raw.errorCode; + if (!isRecord(cursor)) throw new Error("invalid preview event cache"); + const cursorEpoch = cursor.epoch; + const cursorSeq = cursor.seq; + if ( + typeof cursorEpoch !== "string" || + typeof cursorSeq !== "number" || + !Number.isSafeInteger(cursorSeq) || + cursorSeq < 0 + ) + throw new Error("invalid preview event cache"); + let savedUrl: PreviewEventProjection["url"]; + if (url !== undefined) { + if (!isRecord(url)) throw new Error("invalid preview event URL"); + const origin = url.origin; + const pathname = url.pathname; + const hasQuery = url.hasQuery; + if ( + typeof origin !== "string" || + typeof pathname !== "string" || + typeof hasQuery !== "boolean" || + origin.length > 512 || + pathname.length > 1024 + ) + throw new Error("invalid preview event URL"); + savedUrl = Object.freeze({ origin, pathname, hasQuery }); + } + if ( + (timestamp !== undefined && + (typeof timestamp !== "number" || !Number.isSafeInteger(timestamp) || timestamp < 0)) || + (captureId !== undefined && (typeof captureId !== "string" || captureId.length > 256)) || + (errorCode !== undefined && (typeof errorCode !== "string" || errorCode.length > 256)) + ) + throw new Error("invalid preview event cache"); + events.push( + Object.freeze({ + type: raw.type as PreviewEventProjection["type"], + previewId: raw.previewId, + cursor: Object.freeze({ epoch: cursorEpoch, seq: cursorSeq }), + ...(savedUrl === undefined ? {} : { url: savedUrl }), + ...(timestamp === undefined ? {} : { timestamp }), + ...(captureId === undefined ? {} : { captureId }), + ...(errorCode === undefined ? {} : { errorCode }), + }), + ); + } + return Object.freeze(events); +} function identity>(value: T): T { return Object.freeze(safeJson(value) as T); } +function restoredPreviews( + value: unknown, + hostId: string, + sessionId: string, +): ReadonlyMap { + if (value === undefined) return new ImmutableMap(); + if (!Array.isArray(value)) throw new Error("invalid preview cache"); + const previews = new Map(); + for (const pair of value.slice(-32)) { + if ( + !Array.isArray(pair) || + pair.length !== 2 || + typeof pair[0] !== "string" || + !isRecord(pair[1]) + ) + throw new Error("invalid preview cache entry"); + const raw = pair[1]; + const previewId = raw.previewId; + const revision = raw.revision; + const cursor = raw.cursor; + if ( + raw.hostId !== hostId || + raw.sessionId !== sessionId || + typeof previewId !== "string" || + typeof revision !== "string" || + !isRecord(cursor) + ) + throw new Error("invalid preview cache identity"); + const cursorEpoch = cursor.epoch; + const cursorSeq = cursor.seq; + if ( + typeof cursorEpoch !== "string" || + typeof cursorSeq !== "number" || + !Number.isSafeInteger(cursorSeq) || + cursorSeq < 0 + ) + throw new Error("invalid preview cache identity"); + const state = raw.state; + if ( + state !== undefined && + !["launching", "ready", "running", "stopped", "failed"].includes(String(state)) + ) + throw new Error("invalid preview cache state"); + const url = raw.url; + if (url !== undefined && typeof url !== "string") throw new Error("invalid preview cache url"); + const title = raw.title; + if (title !== undefined && typeof title !== "string") throw new Error("invalid preview cache title"); + const canGoBack = raw.canGoBack; + const canGoForward = raw.canGoForward; + if (canGoBack !== undefined && typeof canGoBack !== "boolean") + throw new Error("invalid preview cache navigation"); + if (canGoForward !== undefined && typeof canGoForward !== "boolean") + throw new Error("invalid preview cache navigation"); + const viewport = raw.viewport; + if ( + viewport !== undefined && + (!isRecord(viewport) || + typeof viewport.width !== "number" || + !Number.isSafeInteger(viewport.width) || + typeof viewport.height !== "number" || + !Number.isSafeInteger(viewport.height) || + viewport.width <= 0 || + viewport.height <= 0 || + viewport.width * viewport.height > 16 * 1024 * 1024 || + (viewport.deviceScaleFactor !== undefined && + (typeof viewport.deviceScaleFactor !== "number" || + !Number.isFinite(viewport.deviceScaleFactor) || + viewport.deviceScaleFactor <= 0 || + viewport.deviceScaleFactor > 8))) + ) + throw new Error("invalid preview cache viewport"); + const capture = raw.capture; + if ( + capture !== undefined && + (!isRecord(capture) || + typeof capture.captureId !== "string" || + !["image/png", "image/jpeg", "image/webp"].includes(String(capture.mimeType)) || + typeof capture.size !== "number" || + !Number.isSafeInteger(capture.size) || + capture.size <= 0 || + capture.size > 8 * 1024 * 1024 || + typeof capture.width !== "number" || + !Number.isSafeInteger(capture.width) || + typeof capture.height !== "number" || + !Number.isSafeInteger(capture.height) || + capture.width <= 0 || + capture.height <= 0 || + capture.width * capture.height > 16 * 1024 * 1024 || + typeof capture.capturedAt !== "number" || + !Number.isSafeInteger(capture.capturedAt) || + capture.capturedAt < 0 || + !/^[a-f0-9]{64}$/u.test(String(capture.sha256))) + ) + throw new Error("invalid preview capture cache"); + const error = raw.error; + if ( + error !== undefined && + (!isRecord(error) || typeof error.code !== "string" || typeof error.message !== "string") + ) + throw new Error("invalid preview cache error"); + const authority = restoredPreviewAuthority(raw.authority); + const availableActions = restoredPreviewActions(raw.availableActions); + const identity = { hostId, sessionId, previewId }; + if (pair[0] !== previewKey(identity)) throw new Error("invalid preview cache key"); + const cachedUrl = cachedPreviewUrl(url); + const restored = Object.freeze({ + ...identity, + ...(state === undefined ? {} : { state }), + ...(cachedUrl === undefined ? {} : { url: cachedUrl }), + revision, + cursor: Object.freeze({ epoch: cursorEpoch, seq: cursorSeq }), + ...(title === undefined ? {} : { title }), + ...(canGoBack === undefined ? {} : { canGoBack }), + ...(canGoForward === undefined ? {} : { canGoForward }), + ...(viewport === undefined ? {} : { viewport: Object.freeze({ ...viewport }) }), + ...(capture === undefined ? {} : { capture: Object.freeze({ ...capture }) }), + ...(authority === undefined ? {} : { authority }), + ...(availableActions === undefined ? {} : { availableActions }), + ...(error === undefined ? {} : { error: Object.freeze({ ...error }) }), + freshness: "cached" as const, + }) as unknown as PreviewProjection; + previews.set(pair[0], restored); + } + return new ImmutableMap(previews); +} function terminalValue(value: Record): TerminalProjection { if ( typeof value.terminalId !== "string" || @@ -400,6 +703,8 @@ function restoreSession(value: unknown): SessionProjection | undefined { // written before challenges were excluded from persistence. confirmations: new ImmutableMap(), results: asMap(value.results, resultValue), + previews: restoredPreviews(value.previews, value.hostId, value.sessionId), + previewEvents: restoredPreviewEvents(value.previewEvents), entryIds: new ImmutableSet(entries.map((entry) => String(entry.id))), ...(typeof value.revision === "string" && value.revision.length > 0 && @@ -440,7 +745,7 @@ export function decodeProjectionCache( if ( !isRecord(parsed) || parsed.kind !== "t4-code-projection" || - parsed.version !== PROJECTION_CACHE_VERSION || + (parsed.version !== 1 && parsed.version !== PROJECTION_CACHE_VERSION) || !isRecord(parsed.data) ) throw new Error("unsupported projection cache"); diff --git a/packages/client/src/projection.ts b/packages/client/src/projection.ts index 31d40aa..b37346a 100644 --- a/packages/client/src/projection.ts +++ b/packages/client/src/projection.ts @@ -35,14 +35,17 @@ import { sanitizeRetainedRecord, } from "./transcript-retention.ts"; import type { PublicOmpServerEvent } from "./omp-protocol-provider.ts"; +import { previewKey, type PreviewCaptureMetadata } from "./preview.ts"; export type ProjectionFrame = Exclude>; -type ProjectionEventFrameFromEvent = Event extends PublicOmpServerEvent - ? Readonly<{ type: Event["kind"] } & Event["payload"]> - : never; +type ProjectionEventFrameFromEvent = + Event extends PublicOmpServerEvent ? Readonly<{ type: Event["kind"] } & Event["payload"]> : never; export type ProjectionEventFrame = ProjectionEventFrameFromEvent; type ProjectionInputFrame = ProjectionFrame | ProjectionEventFrame; -type ProjectionInput = Extract; +type ProjectionInput = Extract< + ProjectionInputFrame, + { type: Kind } +>; type ProjectionAgentFrame = ProjectionInput<"agent">; type ProjectionAgentTranscriptFrame = ProjectionInput<"agent.transcript">; type ProjectionAuditFrame = ProjectionInput<"audit">; @@ -51,8 +54,70 @@ type ProjectionFileFrame = ProjectionInput<"files">; type ProjectionGapFrame = ProjectionInput<"gap">; type ProjectionLiveEventFrame = ProjectionInput<"event">; type ProjectionResultFrame = ProjectionInput<"response">; +type ProjectionPreviewFrame = Extract< + ProjectionInputFrame, + { + type: + | "preview.launch" + | "preview.state" + | "preview.navigation" + | "preview.capture" + | "preview.error"; + } +>; type ProjectionReviewFrame = ProjectionInput<"review">; export type ProjectionFreshness = "fresh" | "catching-up" | "cached"; +export type PreviewFreshness = ProjectionFreshness | "stale"; +export type PreviewAction = + | "activate" + | "navigate" + | "back" + | "forward" + | "reload" + | "close" + | "capture" + | "click" + | "fill" + | "type" + | "press" + | "scroll" + | "select" + | "upload" + | "handoff"; +export interface PreviewAuthorityProjection { + readonly id: string; + readonly label: string; + readonly kind: "isolated-session" | "authenticated-profile"; + readonly requiresExplicitOptIn: boolean; +} +export interface PreviewEventProjection { + readonly type: "launch" | "navigation" | "capture" | "error"; + readonly previewId: string; + readonly cursor: Cursor; + readonly url?: Readonly<{ origin: string; pathname: string; hasQuery: boolean }>; + readonly timestamp?: number; + readonly captureId?: string; + readonly errorCode?: string; +} +export interface PreviewProjection { + readonly hostId: string; + readonly sessionId: string; + readonly previewId: string; + readonly state?: "launching" | "ready" | "running" | "stopped" | "failed"; + readonly url?: string; + readonly revision: string; + readonly cursor: Cursor; + readonly title?: string; + readonly canGoBack?: boolean; + readonly canGoForward?: boolean; + readonly viewport?: Readonly<{ width: number; height: number; deviceScaleFactor?: number }>; + readonly capture?: PreviewCaptureMetadata; + /** Labels and trust class only; no browser credential or profile state. */ + readonly authority?: PreviewAuthorityProjection; + readonly availableActions?: readonly PreviewAction[]; + readonly error?: Readonly<{ code: string; message: string }>; + readonly freshness: PreviewFreshness; +} export interface TerminalProjection { readonly terminalId: string; @@ -93,6 +158,10 @@ export interface SessionProjection { readonly audit: readonly ProjectionAuditFrame[]; readonly confirmations: ReadonlyMap; readonly results: ReadonlyMap; + /** Preview metadata only. Decoded pixels and object URLs belong to PreviewCaptureResource. */ + readonly previews: ReadonlyMap; + /** Bounded, cursor-deduplicated activity metadata for the preview workspace. */ + readonly previewEvents: readonly PreviewEventProjection[]; readonly revision?: string; readonly cursor?: Cursor; readonly epoch?: string; @@ -163,6 +232,10 @@ export interface ProjectionOptions { readonly maxFilesBytes?: number; /** UTF-8 bytes retained by one file path and its content. */ readonly maxFileBytes?: number; + /** Maximum retained preview metadata records per warm session. */ + readonly maxPreviews?: number; + /** Maximum retained sanitized preview activity records per warm session. */ + readonly maxPreviewEvents?: number; } export interface ProjectionSubscription { @@ -176,6 +249,8 @@ export const MAX_RETAINED_TERMINAL_BYTES_PER_TERMINAL = 256 * 1024; export const MAX_RETAINED_FILES = 256; export const MAX_RETAINED_FILES_BYTES = 4 * 1024 * 1024; export const MAX_RETAINED_FILE_BYTES = 768 * 1024; +export const MAX_RETAINED_PREVIEWS = 32; +export const MAX_RETAINED_PREVIEW_EVENTS = 128; const DEFAULT_OPTIONS: Required = { maxWarmSessions: 8, maxIndexedSessions: MAX_INDEXED_SESSION_REFS, @@ -195,6 +270,8 @@ const DEFAULT_OPTIONS: Required = { maxFiles: MAX_RETAINED_FILES, maxFilesBytes: MAX_RETAINED_FILES_BYTES, maxFileBytes: MAX_RETAINED_FILE_BYTES, + maxPreviews: MAX_RETAINED_PREVIEWS, + maxPreviewEvents: MAX_RETAINED_PREVIEW_EVENTS, }; const EMPTY_MAP: ReadonlyMap = new ImmutableMap(); const UTF8_ENCODER = new TextEncoder(); @@ -212,10 +289,7 @@ function resolveProjectionOptions(options: ProjectionOptions): Required options.maxFilesBytes) { const oldestWithContent = [...next].find( - ([candidatePath, candidate]) => - candidatePath !== path && candidate.content !== undefined, + ([candidatePath, candidate]) => candidatePath !== path && candidate.content !== undefined, ); if (oldestWithContent === undefined) break; const metadata = fileWithoutContent(oldestWithContent[1]); @@ -480,10 +548,7 @@ function retainFileProjection( const current = next.get(path); if (current !== undefined && totalBytes > options.maxFilesBytes) { const otherBytes = totalBytes - fileProjectionBytes(current); - const trimmed = trimFileProjection( - current, - Math.max(0, options.maxFilesBytes - otherBytes), - ); + const trimmed = trimFileProjection(current, Math.max(0, options.maxFilesBytes - otherBytes)); totalBytes += fileProjectionBytes(trimmed) - fileProjectionBytes(current); next.set(path, trimmed); } @@ -551,6 +616,8 @@ function initialSession( confirmations: EMPTY_MAP, results: EMPTY_MAP, freshness, + previews: EMPTY_MAP, + previewEvents: freezeArray([]), transcriptEventArrivalOrdinal: 0, contextMaintenanceEventArrivalOrdinal: 0, }); @@ -647,6 +714,175 @@ function cursorState(session: SessionProjection, cursor: Cursor): "accept" | "du if (cursor.seq <= session.cursor.seq) return "duplicate"; return cursor.seq === session.cursor.seq + 1 ? "accept" : "gap"; } +function previewCursorState( + preview: PreviewProjection | undefined, + cursor: Cursor, +): "accept" | "duplicate" | "gap" { + if (preview === undefined) return "accept"; + if (cursor.epoch !== preview.cursor.epoch) return "gap"; + if (cursor.seq <= preview.cursor.seq) return "duplicate"; + return cursor.seq === preview.cursor.seq + 1 ? "accept" : "gap"; +} +const PREVIEW_ACTIONS: readonly PreviewAction[] = [ + "activate", + "navigate", + "back", + "forward", + "reload", + "close", + "capture", + "click", + "fill", + "type", + "press", + "scroll", + "select", + "upload", + "handoff", +]; + +function previewAuthority(value: unknown): PreviewAuthorityProjection | undefined { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + !("id" in value) || + !("label" in value) || + !("kind" in value) || + !("requiresExplicitOptIn" in value) + ) + return undefined; + const { id, label, kind, requiresExplicitOptIn } = value; + if ( + typeof id !== "string" || + id.length === 0 || + id.length > 128 || + typeof label !== "string" || + label.length > 256 || + (kind !== "isolated-session" && kind !== "authenticated-profile") || + typeof requiresExplicitOptIn !== "boolean" + ) + return undefined; + return Object.freeze({ id, label, kind, requiresExplicitOptIn }); +} + +function previewActions(value: unknown): readonly PreviewAction[] | undefined { + if (!Array.isArray(value) || value.length > PREVIEW_ACTIONS.length) return undefined; + const actions = value.filter( + (action): action is PreviewAction => + typeof action === "string" && PREVIEW_ACTIONS.includes(action as PreviewAction), + ); + return actions.length === value.length && new Set(actions).size === actions.length + ? Object.freeze(actions) + : undefined; +} + +function previewProjection( + frame: ProjectionPreviewFrame, + previous: PreviewProjection | undefined, +): PreviewProjection { + + const hostId = String(frame.hostId); + const sessionId = String(frame.sessionId); + const previewId = String(frame.previewId); + const authority = previewAuthority(frame.authority); + const availableActions = previewActions(frame.availableActions); + if (frame.type === "preview.error") + return Object.freeze({ + hostId, + sessionId, + previewId, + state: "failed", + revision: String(frame.revision), + cursor: Object.freeze({ ...frame.cursor }), + ...(previous?.url === undefined ? {} : { url: previous.url }), + ...(previous?.title === undefined ? {} : { title: previous.title }), + ...(previous?.canGoBack === undefined ? {} : { canGoBack: previous.canGoBack }), + ...(previous?.canGoForward === undefined ? {} : { canGoForward: previous.canGoForward }), + ...(previous?.viewport === undefined ? {} : { viewport: previous.viewport }), + ...(previous?.capture === undefined ? {} : { capture: previous.capture }), + ...(previous?.authority === undefined ? {} : { authority: previous.authority }), + ...(previous?.availableActions === undefined + ? {} + : { availableActions: previous.availableActions }), + error: Object.freeze({ code: frame.code, message: frame.message }), + freshness: "fresh", + }); + return Object.freeze({ + hostId, + sessionId, + previewId, + state: frame.state, + url: frame.url, + revision: String(frame.revision), + cursor: Object.freeze({ ...frame.cursor }), + ...(frame.title === undefined ? {} : { title: frame.title }), + ...(frame.canGoBack === undefined ? {} : { canGoBack: frame.canGoBack }), + ...(frame.canGoForward === undefined ? {} : { canGoForward: frame.canGoForward }), + ...(frame.viewport === undefined ? {} : { viewport: Object.freeze({ ...frame.viewport }) }), + ...(frame.capture === undefined ? {} : { capture: Object.freeze({ ...frame.capture }) }), + ...(authority === undefined ? {} : { authority }), + ...(availableActions === undefined ? {} : { availableActions }), + ...(frame.type === "preview.state" && frame.error !== undefined + ? { error: Object.freeze({ code: "preview_state", message: frame.error }) } + : {}), + freshness: "fresh", + }); +} + +function previewActivity( + frame: ProjectionPreviewFrame, + preview: PreviewProjection, +): PreviewEventProjection | null { + if (frame.type === "preview.state") return null; + const type = + frame.type === "preview.launch" + ? "launch" + : frame.type === "preview.navigation" + ? "navigation" + : frame.type === "preview.capture" + ? "capture" + : "error"; + let url: PreviewEventProjection["url"]; + if (preview.url !== undefined) { + try { + const parsed = new URL(preview.url); + url = Object.freeze({ + origin: parsed.origin.slice(0, 512), + pathname: parsed.pathname.slice(0, 1024), + hasQuery: parsed.search.length > 0, + }); + } catch { + // The wire decoder rejects malformed URLs; keep defensive projection behavior for stale cache/tests. + } + } + return Object.freeze({ + type, + previewId: preview.previewId, + cursor: Object.freeze({ ...preview.cursor }), + ...(url === undefined ? {} : { url }), + ...(preview.capture === undefined ? {} : { timestamp: preview.capture.capturedAt }), + ...(preview.capture === undefined ? {} : { captureId: preview.capture.captureId }), + ...(preview.error === undefined ? {} : { errorCode: preview.error.code }), + }); +} + +function appendPreviewActivity( + events: readonly PreviewEventProjection[], + event: PreviewEventProjection, + max: number, +): readonly PreviewEventProjection[] { + if ( + events.some( + (previous) => + previous.previewId === event.previewId && + previous.cursor.epoch === event.cursor.epoch && + previous.cursor.seq === event.cursor.seq, + ) + ) + return events; + return appendBounded(events, event, max); +} function sessionDeltaCursorIsStale(previous: Cursor | undefined, cursor: Cursor): boolean { return previous !== undefined && previous.epoch === cursor.epoch && cursor.seq <= previous.seq; } @@ -737,7 +973,10 @@ function resultProjection(frame: ProjectionResultFrame): ResultProjection { return Object.freeze(output); } -function attachAcknowledgesCurrentCursor(session: SessionProjection, frame: ProjectionResultFrame): boolean { +function attachAcknowledgesCurrentCursor( + session: SessionProjection, + frame: ProjectionResultFrame, +): boolean { if ( !frame.ok || frame.command !== "session.attach" || @@ -1117,6 +1356,79 @@ function applyProjectionInput( config, ); } + case "preview.launch": + case "preview.state": + case "preview.navigation": + case "preview.capture": + case "preview.error": { + const sessionKey = key(String(frame.hostId), String(frame.sessionId)); + const previewIdentity = { + hostId: String(frame.hostId), + sessionId: String(frame.sessionId), + previewId: String(frame.previewId), + }; + const previewMapKey = previewKey(previewIdentity); + const current = snapshot.sessions.get(sessionKey)?.previews.get(previewMapKey); + const order = previewCursorState(current, frame.cursor); + const baseline = frame.type === "preview.launch" || frame.type === "preview.state"; + if (!baseline && order === "duplicate") return snapshot; + if (order === "gap" && !baseline) + return withSession( + snapshot, + sessionKey, + (session) => { + const previous = session.previews.get(previewMapKey); + return previous === undefined || previous.freshness === "stale" + ? session + : Object.freeze({ + ...session, + previews: mapWith( + session.previews, + previewMapKey, + Object.freeze({ ...previous, freshness: "stale" as const }), + config.maxPreviews, + ), + }); + }, + config, + ); + if (current !== undefined && current.freshness !== "fresh" && !baseline) + return withSession( + snapshot, + sessionKey, + (session) => { + const previous = session.previews.get(previewMapKey); + return previous === undefined || previous.freshness === "stale" + ? session + : Object.freeze({ + ...session, + previews: mapWith( + session.previews, + previewMapKey, + Object.freeze({ ...previous, freshness: "stale" as const }), + config.maxPreviews, + ), + }); + }, + config, + ); + const projected = previewProjection(frame, current); + const activity = previewActivity(frame, projected); + return withSession( + snapshot, + sessionKey, + (session) => + Object.freeze({ + ...session, + previews: mapWith(session.previews, previewMapKey, projected, config.maxPreviews), + previewEvents: + activity === null + ? session.previewEvents + : appendPreviewActivity(session.previewEvents, activity, config.maxPreviewEvents), + }), + config, + ); + } case "agent": { const sessionKey = key(String(frame.hostId), String(frame.sessionId)); return withSession( @@ -1310,12 +1622,6 @@ function applyProjectionInput( // but do not let their old completeness metadata prove that a route is // gone until the host sends the next authoritative sessions frame. const sessionIndexMetadata = mapWithout(snapshot.sessionIndexMetadata, String(frame.hostId)); - if (snapshot.epoch === undefined || snapshot.epoch === frame.epoch) { - return updateRoot(Object.freeze({ ...snapshot, sessionIndexMetadata }), { - epoch: frame.epoch, - freshness: "fresh", - }); - } const sessions = immutableMap( [...snapshot.sessions.entries()].map( ([sessionKey, session]) => @@ -1323,13 +1629,32 @@ function applyProjectionInput( sessionKey, Object.freeze({ ...session, - freshness: "catching-up", - transcriptEventArrivalOrdinal: 0, - contextMaintenanceEventArrivalOrdinal: 0, + ...(snapshot.epoch === undefined || snapshot.epoch === frame.epoch + ? {} + : { + freshness: "catching-up" as const, + transcriptEventArrivalOrdinal: 0, + contextMaintenanceEventArrivalOrdinal: 0, + }), + previews: immutableMap( + [...session.previews.entries()].map( + ([previewMapKey, preview]) => + [ + previewMapKey, + Object.freeze({ ...preview, freshness: "catching-up" as const }), + ] as const, + ), + ), }), ] as const, ), ); + if (snapshot.epoch === undefined || snapshot.epoch === frame.epoch) { + return updateRoot(Object.freeze({ ...snapshot, sessionIndexMetadata, sessions }), { + epoch: frame.epoch, + freshness: "fresh", + }); + } return Object.freeze({ ...snapshot, sessionIndexMetadata, diff --git a/packages/client/test/client-reconnect.test.ts b/packages/client/test/client-reconnect.test.ts index c5e84df..3adde40 100644 --- a/packages/client/test/client-reconnect.test.ts +++ b/packages/client/test/client-reconnect.test.ts @@ -917,4 +917,114 @@ describe("OmpClient reconnect stability", () => { expect(client.snapshot().attempt).toBe(1); await client.close(); }); + it("requests preview state after initial and reconnect attachments without delaying readiness", async () => { + const clock = new FakeClock(); + const respond = (frame: ClientFrame, transport: FakeTransport): void => { + if ( + frame.type === "command" && + (frame.command === "session.attach" || frame.command === "preview.state") + ) + transport.emit(responseFor(frame, frame.command === "preview.state" ? { previews: [] } : {})); + }; + const previewCapabilities = [ + "sessions.read", + "sessions.prompt", + "sessions.control", + "sessions.manage", + "preview.control", + "preview.read", + ]; + const first = new FakeTransport({ + welcome: welcome({ grantedCapabilities: previewCapabilities }), + onSend: respond, + }); + const second = new FakeTransport({ + welcome: welcome({ grantedCapabilities: previewCapabilities }), + onSend: respond, + }); + const third = new FakeTransport({ + welcome: welcome({ grantedCapabilities: previewCapabilities }), + onSend: respond, + }); + const transports = [first, second, third]; + const client = new OmpClient({ + transport: () => transports.shift() ?? new FakeTransport(), + hostId: HOST, + clock, + timers: clock, + random: () => 0, + reconnect: { baseMs: 0, maxMs: 0 }, + }); + + await client.connect(); + await client.attach(HOST, SESSION); + await Promise.resolve(); + await Promise.resolve(); + expect( + first.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach", "preview.state"]); + first.drop(); + await flushReconnect(clock); + await Promise.resolve(); + expect(client.state).toBe("ready"); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect( + second.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach", "preview.state"]); + + second.drop(); + await flushReconnect(clock); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect( + third.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach", "preview.state"]); + await client.close(); + }); + + it("does not request preview state when reconnecting without preview control", async () => { + const clock = new FakeClock(); + const respondAttach = (frame: ClientFrame, transport: FakeTransport): void => { + if (frame.type === "command" && frame.command === "session.attach") + transport.emit(responseFor(frame)); + }; + const first = new FakeTransport({ welcome: welcome(), onSend: respondAttach }); + const second = new FakeTransport({ welcome: welcome(), onSend: respondAttach }); + const transports = [first, second]; + const client = new OmpClient({ + transport: () => transports.shift() ?? new FakeTransport(), + hostId: HOST, + clock, + timers: clock, + random: () => 0, + reconnect: { baseMs: 0, maxMs: 0 }, + }); + + await client.connect(); + await client.attach(HOST, SESSION); + first.drop(); + await flushReconnect(clock); + await Promise.resolve(); + expect(client.state).toBe("ready"); + expect( + second.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach"]); + await client.close(); + }); }); diff --git a/packages/client/test/preview-lease.test.ts b/packages/client/test/preview-lease.test.ts new file mode 100644 index 0000000..07fdaa7 --- /dev/null +++ b/packages/client/test/preview-lease.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; +import { PreviewLeaseManager, type PreviewIdentity } from "../src/index.ts"; + +describe("PreviewLeaseManager", () => { + it("binds leases to their full preview identity and releases best-effort", async () => { + const calls: Array<{ command: string; previewId: string; leaseId?: string }> = []; + let now = 0; + const manager = new PreviewLeaseManager( + { + previewLeaseAcquire: async (identity) => { + calls.push({ command: "acquire", previewId: identity.previewId }); + return { + ok: true, + result: { previewId: identity.previewId, leaseId: `lease-${identity.previewId}`, expiresAt: 100 }, + }; + }, + previewLeaseRenew: async (identity) => { + calls.push({ + command: "renew", + previewId: identity.previewId, + ...(identity.leaseId === undefined ? {} : { leaseId: identity.leaseId }), + }); + return { + ok: true, + result: { previewId: identity.previewId, leaseId: identity.leaseId, expiresAt: 200 }, + }; + }, + previewLeaseRelease: async (identity) => { + calls.push({ + command: "release", + previewId: identity.previewId, + ...(identity.leaseId === undefined ? {} : { leaseId: identity.leaseId }), + }); + return { ok: true }; + }, + }, + { now: () => now, defaultTtlMs: 100 }, + ); + const one: PreviewIdentity = { hostId: "host", sessionId: "session", previewId: "one" }; + const two: PreviewIdentity = { hostId: "host", sessionId: "session", previewId: "two" }; + + expect(await manager.ensure(one)).toBe("lease-one"); + expect(await manager.ensure(one)).toBe("lease-one"); + expect(calls).toEqual([{ command: "acquire", previewId: "one" }]); + now = 60; + expect(await manager.ensure(one)).toBe("lease-one"); + expect(await manager.ensure(two)).toBe("lease-two"); + await manager.release(one); + + expect(calls).toEqual([ + { command: "acquire", previewId: "one" }, + { command: "renew", previewId: "one", leaseId: "lease-one" }, + { command: "acquire", previewId: "two" }, + { command: "release", previewId: "one", leaseId: "lease-one" }, + ]); + }); + + it("invalidates a lease after canonical ownership failures", async () => { + let acquired = 0; + const manager = new PreviewLeaseManager({ + previewLeaseAcquire: async (identity) => { + acquired += 1; + return { + ok: true, + result: { + previewId: identity.previewId, + leaseId: `lease-${acquired}`, + expiresAt: 10_000, + }, + }; + }, + previewLeaseRenew: async () => ({ ok: true, result: {} }), + previewLeaseRelease: async () => ({ ok: true }), + }); + const identity: PreviewIdentity = { hostId: "host", sessionId: "session", previewId: "preview" }; + + await expect( + manager.mutate(identity, async () => Promise.reject({ code: "CONFLICT" })), + ).rejects.toEqual({ code: "CONFLICT" }); + expect(await manager.ensure(identity)).toBe("lease-2"); + expect(acquired).toBe(2); + }); +}); diff --git a/packages/client/test/projection.test.ts b/packages/client/test/projection.test.ts index ab5957b..1235848 100644 --- a/packages/client/test/projection.test.ts +++ b/packages/client/test/projection.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; import { hostId, revision, sessionId, type DurableEntry, type SessionRef } from "@t4-code/protocol"; import { MAX_INDEXED_SESSION_REFS } from "../src/projection.ts"; +import { PreviewCaptureResource, previewKey } from "../src/preview.ts"; import { MAX_PROJECTION_CACHE_BYTES, ProjectionStore, @@ -1202,7 +1203,7 @@ describe("client projections", () => { detail: { title: "Verify parity", index: 0, - resumable: true, + resumable: false, contextUsage: { used: 2_000, limit: 8_000 }, }, }); @@ -1718,4 +1719,456 @@ describe("client projections", () => { store.applyPublicFrame({ ...frame("event"), cursor: { epoch: "e1", seq: 3 } }); expect(calls).toBe(2); }); + + it("orders preview metadata by full identity and requires a fresh baseline after reconnect", () => { + const identity = { hostId: String(HOST), sessionId: "session-a", previewId: "preview-a" }; + const launch = { + v: V, + type: "preview.launch", + ...identity, + state: "ready", + url: "https://example.test/one", + revision: revision("preview-1"), + cursor: { epoch: "preview-e1", seq: 1 }, + } as ProjectionFrame; + const skippedNavigation = { + ...launch, + type: "preview.navigation", + url: "https://example.test/stale", + revision: revision("preview-3"), + cursor: { epoch: "preview-e1", seq: 3 }, + } as ProjectionFrame; + const baseline = { + ...launch, + type: "preview.state", + url: "https://example.test/current", + revision: revision("preview-4"), + cursor: { epoch: "preview-e1", seq: 4 }, + } as ProjectionFrame; + let state = applyPublicFrame(createProjectionSnapshot(), launch); + state = applyPublicFrame(state, skippedNavigation); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity))?.freshness, + ).toBe("stale"); + state = applyPublicFrame(state, baseline); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity)), + ).toMatchObject({ + url: "https://example.test/current", + freshness: "fresh", + }); + state = applyPublicFrame(state, { + ...baseline, + type: "preview.navigation", + url: "https://example.test/old", + cursor: { epoch: "preview-e1", seq: 3 }, + } as ProjectionFrame); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity))?.url, + ).toBe("https://example.test/current"); + state = applyPublicFrame(state, frame("welcome")); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity))?.freshness, + ).toBe("catching-up"); + state = applyPublicFrame(state, { + ...baseline, + url: "https://example.test/recovered", + cursor: { epoch: "preview-e1", seq: 1 }, + revision: revision("preview-5"), + } as ProjectionFrame); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity))?.freshness, + ).toBe("fresh"); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity))?.url, + ).toBe("https://example.test/recovered"); + + const otherIdentity = { + hostId: "preview-other", + sessionId: "session-a", + previewId: "preview-a", + }; + state = applyPublicFrame(state, { + ...launch, + ...otherIdentity, + hostId: hostId(otherIdentity.hostId), + sessionId: sessionId(otherIdentity.sessionId), + cursor: { epoch: "preview-other-e1", seq: 1 }, + } as ProjectionFrame); + expect( + state.sessions + .get(`${otherIdentity.hostId}\u0000${otherIdentity.sessionId}`) + ?.previews.get(previewKey(otherIdentity))?.hostId, + ).toBe(otherIdentity.hostId); + expect( + state.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity))?.hostId, + ).toBe(String(HOST)); + }); + + it("persists preview metadata without capture bytes or object URLs and migrates version-one caches", () => { + const identity = { hostId: String(HOST), sessionId: "session-a", previewId: "preview-cache" }; + const state = applyPublicFrame(createProjectionSnapshot(), { + v: V, + type: "preview.capture", + ...identity, + state: "ready", + url: "https://example.test/cache", + revision: revision("preview-cache-1"), + cursor: { epoch: "preview-cache-e1", seq: 1 }, + capture: { + captureId: "capture-cache", + mimeType: "image/png", + size: 24, + width: 1, + height: 1, + capturedAt: 1, + sha256: "a".repeat(64), + }, + } as ProjectionFrame); + const serialized = encodeProjectionCache(state); + expect(serialized).not.toContain("objectUrl"); + expect(serialized).not.toContain("base64"); + const restored = decodeProjectionCacheValue(serialized); + expect( + restored?.sessions.get(sessionKey("session-a"))?.previews.get(previewKey(identity)), + ).toMatchObject({ + capture: { captureId: "capture-cache" }, + freshness: "cached", + }); + const versionOne = JSON.parse(serialized) as { + version: number; + data: { sessions: Array<{ value: Record }> }; + }; + versionOne.version = 1; + for (const item of versionOne.data.sessions) delete item.value.previews; + expect( + decodeProjectionCacheValue(JSON.stringify(versionOne))?.sessions.get(sessionKey("session-a")) + ?.previews.size, + ).toBe(0); + }); + + it("assembles bounded preview capture chunks and revokes replaced object URLs", async () => { + const identity = { + hostId: "capture-host", + sessionId: "capture-session", + previewId: "capture-preview", + }; + const png = new Uint8Array(24); + png.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]); + png[19] = 1; + png[23] = 1; + const content = Buffer.from(png).toString("base64"); + const revoked: string[] = []; + let created = 0; + const resource = new PreviewCaptureResource({ + read: async (_identity, captureId, offset) => ({ + previewId: identity.previewId, + captureId, + size: png.byteLength, + offset, + nextOffset: png.byteLength, + complete: true, + content, + }), + sha256: async () => "a".repeat(64), + createObjectURL: () => `blob:test-${++created}`, + revokeObjectURL: (url) => revoked.push(url), + }); + const capture = { + captureId: "capture-one", + mimeType: "image/png" as const, + size: png.byteLength, + width: 1, + height: 1, + capturedAt: 1, + sha256: "a".repeat(64), + }; + expect(await resource.objectUrl(identity, capture)).toBe("blob:test-1"); + expect(await resource.objectUrl(identity, { ...capture, captureId: "capture-two" })).toBe( + "blob:test-2", + ); + expect(revoked).toEqual(["blob:test-1"]); + resource.dispose(); + expect(revoked).toEqual(["blob:test-1", "blob:test-2"]); + }); + + it("rejects malformed preview chunks and hash mismatches", async () => { + const identity = { + hostId: "capture-host", + sessionId: "capture-session", + previewId: "capture-preview", + }; + const capture = { + captureId: "capture-invalid", + mimeType: "image/png" as const, + size: 24, + width: 1, + height: 1, + capturedAt: 1, + sha256: "a".repeat(64), + }; + const malformed = new PreviewCaptureResource({ + read: async () => ({ + previewId: identity.previewId, + captureId: capture.captureId, + size: capture.size, + offset: 1, + nextOffset: capture.size, + complete: true, + content: Buffer.alloc(capture.size).toString("base64"), + }), + sha256: async () => capture.sha256, + createObjectURL: () => "blob:unused", + revokeObjectURL: () => undefined, + }); + await expect(malformed.objectUrl(identity, capture)).rejects.toThrow( + "identity or offset mismatch", + ); + const hashMismatch = new PreviewCaptureResource({ + read: async () => ({ + previewId: identity.previewId, + captureId: capture.captureId, + size: capture.size, + offset: 0, + nextOffset: capture.size, + complete: true, + content: Buffer.from([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, + ]).toString("base64"), + }), + sha256: async () => "b".repeat(64), + createObjectURL: () => "blob:unused", + revokeObjectURL: () => undefined, + }); + await expect(hashMismatch.objectUrl(identity, capture)).rejects.toThrow("hash mismatch"); + }); + it("rejects oversized chunks and raster magic or dimension mismatches", async () => { + const identity = { + hostId: "capture-host", + sessionId: "capture-session", + previewId: "capture-preview", + }; + const png = new Uint8Array(24); + png.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]); + png[19] = 1; + png[23] = 1; + const capture = { + captureId: "capture-raster", + mimeType: "image/png" as const, + size: png.byteLength, + width: 1, + height: 1, + capturedAt: 1, + sha256: "a".repeat(64), + }; + await expect( + new PreviewCaptureResource({ + read: async () => ({ + previewId: identity.previewId, + captureId: "capture-large", + size: 256 * 1024 + 1, + offset: 0, + nextOffset: 256 * 1024 + 1, + complete: true, + content: "", + }), + sha256: async () => "a".repeat(64), + }).objectUrl(identity, { + ...capture, + captureId: "capture-large", + size: 256 * 1024 + 1, + }), + ).rejects.toThrow("chunk bounds mismatch"); + await expect( + new PreviewCaptureResource({ + read: async () => ({ + previewId: identity.previewId, + captureId: capture.captureId, + size: capture.size, + offset: 0, + nextOffset: capture.size, + complete: true, + content: Buffer.alloc(capture.size).toString("base64"), + }), + sha256: async () => capture.sha256, + createObjectURL: () => "blob:unused", + revokeObjectURL: () => undefined, + }).objectUrl(identity, capture), + ).rejects.toThrow("not PNG"); + await expect( + new PreviewCaptureResource({ + read: async () => ({ + previewId: identity.previewId, + captureId: capture.captureId, + size: capture.size, + offset: 0, + nextOffset: capture.size, + complete: true, + content: Buffer.from(png).toString("base64"), + }), + sha256: async () => capture.sha256, + createObjectURL: () => "blob:unused", + revokeObjectURL: () => undefined, + }).objectUrl(identity, { ...capture, width: 2 }), + ).rejects.toThrow("dimensions mismatch"); + }); + + it("deduplicates in-flight loads and drops replaced pending capture ownership", async () => { + const identity = { + hostId: "capture-host", + sessionId: "capture-session", + previewId: "capture-preview", + }; + const png = new Uint8Array(24); + png.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]); + png[19] = 1; + png[23] = 1; + const capture = { + captureId: "capture-race", + mimeType: "image/png" as const, + size: png.byteLength, + width: 1, + height: 1, + capturedAt: 1, + sha256: "a".repeat(64), + }; + const deferred = Promise.withResolvers<{ + previewId: string; + captureId: string; + size: number; + offset: number; + nextOffset: number; + complete: boolean; + content: string; + }>(); + let reads = 0; + let created = 0; + const resource = new PreviewCaptureResource({ + read: async () => { + reads += 1; + return deferred.promise; + }, + sha256: async () => capture.sha256, + createObjectURL: () => `blob:race-${++created}`, + revokeObjectURL: () => undefined, + }); + const first = resource.objectUrl(identity, capture); + const second = resource.objectUrl(identity, capture); + expect(reads).toBe(1); + deferred.resolve({ + previewId: identity.previewId, + captureId: capture.captureId, + size: capture.size, + offset: 0, + nextOffset: capture.size, + complete: true, + content: Buffer.from(png).toString("base64"), + }); + expect(await first).toBe("blob:race-1"); + expect(await second).toBe("blob:race-1"); + expect(created).toBe(1); + + const delayed = Promise.withResolvers<{ + previewId: string; + captureId: string; + size: number; + offset: number; + nextOffset: number; + complete: boolean; + content: string; + }>(); + const replacement = { ...capture, captureId: "capture-replacement" }; + const racing = new PreviewCaptureResource({ + read: async (_identity, captureId) => + captureId === capture.captureId + ? delayed.promise + : { + previewId: identity.previewId, + captureId, + size: replacement.size, + offset: 0, + nextOffset: replacement.size, + complete: true, + content: Buffer.from(png).toString("base64"), + }, + sha256: async () => capture.sha256, + createObjectURL: () => "blob:replacement", + revokeObjectURL: () => undefined, + }); + const pending = racing.objectUrl(identity, capture); + racing.replace(identity, replacement); + delayed.resolve({ + previewId: identity.previewId, + captureId: capture.captureId, + size: capture.size, + offset: 0, + nextOffset: capture.size, + complete: true, + content: Buffer.from(png).toString("base64"), + }); + await expect(pending).rejects.toThrow("replaced while loading"); + expect(await racing.objectUrl(identity, replacement)).toBe("blob:replacement"); + }); + + it("persists authority labels and sanitized preview activity without URL query or hash", () => { + const identity = { hostId: String(HOST), sessionId: "session-a", previewId: "preview-authority" }; + const state = applyPublicFrame(createProjectionSnapshot(), { + v: V, + type: "preview.launch", + hostId: HOST, + sessionId: sessionId("session-a"), + previewId: "preview-authority" as never, + state: "ready", + url: "https://preview.test/workspace?token=never-cache#secret", + revision: revision("preview-authority-1"), + cursor: { epoch: "preview-authority", seq: 1 }, + authority: { + id: "omp-session", + label: "OMP session", + kind: "isolated-session", + requiresExplicitOptIn: false, + }, + availableActions: ["activate", "fill", "select", "upload"], + } as ProjectionFrame); + const session = state.sessions.get(sessionKey("session-a"))!; + expect(session.previews.get(previewKey(identity))).toMatchObject({ + authority: { id: "omp-session", kind: "isolated-session" }, + availableActions: ["activate", "fill", "select", "upload"], + }); + expect(session.previewEvents).toEqual([ + { + type: "launch", + previewId: "preview-authority", + cursor: { epoch: "preview-authority", seq: 1 }, + url: { origin: "https://preview.test", pathname: "/workspace", hasQuery: true }, + }, + ]); + const cache = encodeProjectionCache(state); + expect(cache).not.toContain("token=never-cache"); + expect(cache).not.toContain("#secret"); + expect(decodeProjectionCacheValue(cache)?.sessions.get(sessionKey("session-a"))?.previews + .get(previewKey(identity))?.url).toBe("https://preview.test/workspace"); + }); + + it("does not classify preview state reconciliation as an error activity", () => { + const identity = { hostId: String(HOST), sessionId: "session-a", previewId: "preview-state" }; + const state = applyPublicFrame(createProjectionSnapshot(), { + v: V, + type: "preview.state", + hostId: HOST, + sessionId: sessionId("session-a"), + previewId: "preview-state" as never, + state: "ready", + url: "https://preview.test/current", + revision: revision("preview-state-1"), + cursor: { epoch: "preview-state", seq: 1 }, + } as ProjectionFrame); + + const session = state.sessions.get(sessionKey("session-a"))!; + expect(session.previews.get(previewKey(identity))).toMatchObject({ + state: "ready", + url: "https://preview.test/current", + }); + expect(session.previewEvents).toEqual([]); + }); }); diff --git a/packages/fixture-server/src/engine.ts b/packages/fixture-server/src/engine.ts index db199b3..43cee64 100644 --- a/packages/fixture-server/src/engine.ts +++ b/packages/fixture-server/src/engine.ts @@ -11,10 +11,16 @@ import { type HostId, type Revision, type ServerFrame, + type PreviewId, type SessionId, type SessionRef, } from "@t4-code/protocol"; -import { buildCommandSideFrames } from "./fixture-command-frames.ts"; +import { + buildCommandSideFrames, + FIXTURE_PREVIEW_CAPTURE_BASE64, + fixturePreviewSnapshot, + isPreviewEventCommand, +} from "./fixture-command-frames.ts"; import { applyCreatedSessionManagementMutation, applyCreatedSessionModelMutation, @@ -80,6 +86,7 @@ export class FixtureEngine { private clients = new Map(); private nextClient = 1; private seq = 0; + private previewSeq = 0; private sessionIndexSeq = 0; private durableCount = 0; private epoch: string; @@ -375,6 +382,32 @@ export class FixtureEngine { ? this.durableEntries : (this.createdSessions.get(String(sessionId))?.durableEntries ?? []); } + private previewCursorFor(sessionId: SessionId | undefined): Cursor { + const isDefault = sessionId === undefined || sessionId === this.seed.sessionId; + const seq = isDefault ? this.previewSeq : (this.createdSessions.get(String(sessionId))?.previewSeq ?? 0); + return sessionCursor(this.seed, seq, `${this.epoch}-preview`); + } + private previewRevisionFor(sessionId: SessionId | undefined): Revision { + const isDefault = sessionId === undefined || sessionId === this.seed.sessionId; + if (isDefault) { + return derivedRevision(this.seed, `preview-${this.previewSeq}`); + } + const created = this.createdSessions.get(String(sessionId)); + const seq = created?.previewSeq ?? 0; + const ordinal = created?.ordinal ?? 0; + return derivedRevision(this.seed, `created-${ordinal}-preview-${seq}`); + } + private incrementPreviewSeq(command: string, sessionId: SessionId | undefined): void { + if (isPreviewEventCommand(command)) { + const session = sessionId ?? branded(this.seed.sessionId); + const created = this.createdSessions.get(String(session)); + if (created === undefined) { + this.previewSeq += 1; + } else { + created.previewSeq += 1; + } + } + } private currentSessionIndexCursor(): Cursor { return sessionCursor(this.seed, this.sessionIndexSeq, this.epoch); } @@ -420,12 +453,15 @@ export class FixtureEngine { grantedCapabilities: [ "catalog.read", "config.read", + "preview.control", + "preview.input", + "preview.read", "sessions.read", "sessions.prompt", "sessions.control", "sessions.manage", ], - grantedFeatures: ["catalog.metadata", "resume", "settings.metadata"], + grantedFeatures: ["catalog.metadata", "preview.control", "resume", "settings.metadata"], negotiatedLimits: { maxInputBytes: 1_048_576 }, authentication: "local", resumed, @@ -571,6 +607,7 @@ export class FixtureEngine { }); return; } + this.incrementPreviewSeq(frame.command, frame.sessionId); const response = this.makeCommandResponse(frame, base); state.commands.set(key, { payloadHash, response }); this.emit(state, response); @@ -613,6 +650,9 @@ export class FixtureEngine { hostId: branded(this.seed.hostId), ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), }; + if (frame.decision === "approve") { + this.incrementPreviewSeq(command.command, command.sessionId); + } const response = frame.decision === "deny" ? { @@ -694,12 +734,13 @@ export class FixtureEngine { return; } const targetSessionId = frame.sessionId ?? branded(this.seed.sessionId); + const isPreview = isPreviewEventCommand(frame.command); const ids = { v: V, hostId: branded(this.seed.hostId), sessionId: targetSessionId, - cursor: this.cursorFor(targetSessionId), - revision: this.revisionFor(targetSessionId), + cursor: isPreview ? this.previewCursorFor(targetSessionId) : this.cursorFor(targetSessionId), + revision: isPreview ? this.previewRevisionFor(targetSessionId) : this.revisionFor(targetSessionId), }; if (frame.command === "session.list") { this.emit(state, { @@ -782,6 +823,7 @@ export class FixtureEngine { base: Omit, "ok" | "result" | "error">, ): Extract { const actualRevision = this.revisionFor(frame.sessionId); + const targetSessionId = frame.sessionId ?? branded(this.seed.sessionId); if ( frame.expectedRevision !== undefined && frame.expectedRevision !== actualRevision && @@ -867,13 +909,95 @@ export class FixtureEngine { ok: true, result: { watchId: "watch-fixture", cursor: this.currentCursor }, }; + if (frame.command === "preview.policy.check") + return { + ...base, + ok: true, + result: { allowed: true, confirmationRequired: false }, + }; + if (frame.command === "preview.lease.acquire" || frame.command === "preview.lease.renew") { + return { + ...base, + ok: true, + result: { + previewId: branded("preview-fixture"), + leaseId: "lease-fixture", + expiresAt: Date.parse("2999-01-01T00:00:00.000Z"), + }, + }; + } + if (frame.command === "preview.lease.release") + return { + ...base, + ok: true, + result: { previewId: branded("preview-fixture"), released: true }, + }; + if (frame.command === "preview.state") { + const previewIds = { + v: V, + hostId: branded(this.seed.hostId), + sessionId: targetSessionId, + cursor: this.previewCursorFor(targetSessionId), + revision: this.previewRevisionFor(targetSessionId), + }; + const preview = fixturePreviewSnapshot(previewIds, this.seed, { + capture: true, + state: "ready", + url: "http://127.0.0.1/fixture", + }); + return { ...base, ok: true, result: { previews: [preview] } }; + } + if (frame.command === "preview.capture.read") { + const previewIds = { + v: V, + hostId: branded(this.seed.hostId), + sessionId: targetSessionId, + cursor: this.previewCursorFor(targetSessionId), + revision: this.previewRevisionFor(targetSessionId), + }; + const preview = fixturePreviewSnapshot(previewIds, this.seed, { + capture: true, + state: "ready", + url: "http://127.0.0.1/fixture", + }); + const bytes = Buffer.from(FIXTURE_PREVIEW_CAPTURE_BASE64, "base64"); + const offset = Number(frame.args.offset); + const nextOffset = Math.min(bytes.byteLength, offset + bytes.byteLength); + return { + ...base, + ok: true, + result: { + previewId: preview.previewId, + captureId: preview.capture?.captureId ?? "capture-fixture", + size: bytes.byteLength, + offset, + nextOffset, + complete: nextOffset === bytes.byteLength, + content: bytes.subarray(offset, nextOffset).toString("base64"), + }, + }; + } + if (frame.command.startsWith("preview.")) { + const previewIds = { + v: V, + hostId: branded(this.seed.hostId), + sessionId: targetSessionId, + cursor: this.previewCursorFor(targetSessionId), + revision: this.previewRevisionFor(targetSessionId), + }; + const preview = fixturePreviewSnapshot(previewIds, this.seed, { + capture: frame.command === "preview.capture", + state: frame.command === "preview.close" ? "stopped" : "ready", + url: typeof frame.args.url === "string" ? frame.args.url : "http://127.0.0.1/fixture", + }); + return { ...base, ok: true, result: { preview } }; + } if (frame.command.includes(".lease.")) return { ...base, ok: true, result: { leaseId: "lease-fixture", cursor: this.currentCursor }, }; - if (frame.command === "preview.capture") return { ...base, ok: true, result: { content: "" } }; return { ...base, ok: true, result: { accepted: true } }; } private createSession(frame: CommandFrame): CreatedFixtureSession { diff --git a/packages/fixture-server/src/fixture-command-frames.ts b/packages/fixture-server/src/fixture-command-frames.ts index 3fc5bcf..120ad61 100644 --- a/packages/fixture-server/src/fixture-command-frames.ts +++ b/packages/fixture-server/src/fixture-command-frames.ts @@ -1,6 +1,9 @@ import { type CommandFrame, type HostId, + type PreviewCaptureId, + type PreviewId, + type PreviewSnapshot, type Revision, type ServerFrame, type SessionId, @@ -12,7 +15,7 @@ import type { ScenarioSeed } from "./seeds.ts"; const V = "omp-app/1" as const; -interface CommandSideFrameIds { +export interface CommandSideFrameIds { readonly v: typeof V; readonly hostId: HostId; readonly sessionId: SessionId; @@ -20,12 +23,106 @@ interface CommandSideFrameIds { readonly revision: Revision; } +export const FIXTURE_PREVIEW_CAPTURE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; +export const FIXTURE_PREVIEW_CAPTURE_SHA256 = + "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460"; + +const PREVIEW_EVENT_COMMANDS = new Set([ + "preview.activate", + "preview.back", + "preview.capture", + "preview.click", + "preview.close", + "preview.fill", + "preview.forward", + "preview.handoff", + "preview.launch", + "preview.navigate", + "preview.press", + "preview.reload", + "preview.scroll", + "preview.select", + "preview.state", + "preview.type", + "preview.upload", +]); + +export function isPreviewEventCommand(command: string): boolean { + return PREVIEW_EVENT_COMMANDS.has(command); +} + +export function fixturePreviewSnapshot( + ids: CommandSideFrameIds, + seed: ScenarioSeed, + options: { + readonly capture?: boolean; + readonly state?: PreviewSnapshot["state"]; + readonly url?: string; + } = {}, +): PreviewSnapshot { + return { + previewId: branded("preview-fixture"), + state: options.state ?? "ready", + url: options.url ?? "http://127.0.0.1/fixture", + revision: ids.revision, + cursor: ids.cursor, + title: "Fixture preview", + canGoBack: false, + canGoForward: false, + viewport: { width: 1280, height: 720, deviceScaleFactor: 1 }, + authority: { + id: "omp-session", + label: "OMP session", + kind: "isolated-session", + requiresExplicitOptIn: false, + }, + availableActions: [ + "activate", + "navigate", + "back", + "forward", + "reload", + "close", + "capture", + "click", + "fill", + "type", + "press", + "scroll", + "select", + "upload", + "handoff", + ], + ...(options.capture + ? { + capture: { + captureId: branded("capture-fixture"), + mimeType: "image/png", + size: 68, + width: 1, + height: 1, + capturedAt: Date.parse(seed.baseTime), + sha256: FIXTURE_PREVIEW_CAPTURE_SHA256, + }, + } + : {}), + }; +} + export function buildCommandSideFrames( frame: CommandFrame, ids: CommandSideFrameIds, session: SessionRef, seed: ScenarioSeed, ): ServerFrame[] { + const previewUrl = + typeof frame.args.url === "string" ? frame.args.url : "http://127.0.0.1/fixture"; + const preview = fixturePreviewSnapshot(ids, seed, { + capture: frame.command === "preview.capture", + state: frame.command === "preview.close" ? "stopped" : "ready", + url: previewUrl, + }); let additive: unknown; if (frame.command === "host.watch") additive = { ...ids, type: "host.watch", watchId: "watch-fixture", state: "started" }; @@ -108,31 +205,31 @@ export function buildCommandSideFrames( settings: fixtureSettings(), }; else if (frame.command === "preview.launch") - additive = { - ...ids, - type: "preview.launch", - previewId: "preview-fixture", - url: "http://127.0.0.1/fixture", - revision: ids.revision, - }; + additive = { ...ids, ...preview, type: "preview.launch" }; else if (frame.command === "preview.state") - additive = { ...ids, type: "preview.state", previewId: "preview-fixture", state: "ready" }; - else if (frame.command === "preview.navigate") - additive = { - ...ids, - type: "preview.navigation", - previewId: "preview-fixture", - url: "http://127.0.0.1/fixture", - }; + additive = { ...ids, ...preview, type: "preview.state" }; + else if ( + frame.command === "preview.navigate" || + frame.command === "preview.back" || + frame.command === "preview.forward" || + frame.command === "preview.reload" + ) + additive = { ...ids, ...preview, type: "preview.navigation" }; else if (frame.command === "preview.capture") - additive = { - ...ids, - type: "preview.capture", - previewId: "preview-fixture", - content: "", - encoding: "base64", - mimeType: "text/plain", - }; + additive = { ...ids, ...preview, type: "preview.capture" }; + else if ( + frame.command === "preview.activate" || + frame.command === "preview.close" || + frame.command === "preview.click" || + frame.command === "preview.fill" || + frame.command === "preview.handoff" || + frame.command === "preview.press" || + frame.command === "preview.scroll" || + frame.command === "preview.select" || + frame.command === "preview.type" || + frame.command === "preview.upload" + ) + additive = { ...ids, ...preview, type: "preview.state" }; if (Array.isArray(additive)) return additive as ServerFrame[]; return additive === undefined ? [] : [additive as ServerFrame]; diff --git a/packages/fixture-server/src/fixture-sessions.ts b/packages/fixture-server/src/fixture-sessions.ts index 3fb1cf9..7231690 100644 --- a/packages/fixture-server/src/fixture-sessions.ts +++ b/packages/fixture-server/src/fixture-sessions.ts @@ -30,6 +30,7 @@ export interface CreatedFixtureSession { archivedAt?: string; deleted: boolean; seq: number; + previewSeq: number; durableCount: number; nextLiveEntry: number; managementRevision: number; @@ -195,6 +196,7 @@ export function createCreatedFixtureSession( updatedAt: new Date(Date.parse(seed.baseTime) + now + ordinal).toISOString(), deleted: false, seq: 0, + previewSeq: 0, durableCount: 0, nextLiveEntry: 1, managementRevision: 0, diff --git a/packages/fixture-server/test/engine.test.ts b/packages/fixture-server/test/engine.test.ts index 7d2ca06..d9a0700 100644 --- a/packages/fixture-server/test/engine.test.ts +++ b/packages/fixture-server/test/engine.test.ts @@ -771,7 +771,7 @@ describe("deterministic fixture engine", () => { engine.disconnect(client.id); expect(engine.clientCount).toBe(0); }); - it("emits decodable 0.2 additive watch, lease, agent, file, audit, catalog, settings, preview, and terminal frames", () => { + it("emits decodable additive watch, lease, agent, file, audit, catalog, settings, preview, and terminal frames", () => { const engine = new FixtureEngine(loadScenario("basic-v1")); const client = engine.connect("a"); ready(engine, client.id); @@ -788,8 +788,32 @@ describe("deterministic fixture engine", () => { ["settings.read", {}], ["preview.launch", { url: "http://127.0.0.1/fixture" }], ["preview.state", {}], - ["preview.navigate", { url: "http://127.0.0.1/fixture" }], - ["preview.capture", {}], + ["preview.policy.check", { action: "capture", previewId: "preview-fixture" }], + ["preview.lease.acquire", { previewId: "preview-fixture", ttlMs: 30_000 }], + [ + "preview.lease.renew", + { previewId: "preview-fixture", leaseId: "lease-fixture", ttlMs: 30_000 }, + ], + ["preview.lease.release", { previewId: "preview-fixture", leaseId: "lease-fixture" }], + ["preview.navigate", { previewId: "preview-fixture", url: "http://127.0.0.1/fixture" }], + ["preview.back", { previewId: "preview-fixture" }], + ["preview.forward", { previewId: "preview-fixture" }], + ["preview.reload", { previewId: "preview-fixture" }], + ["preview.capture", { previewId: "preview-fixture" }], + [ + "preview.capture.read", + { previewId: "preview-fixture", captureId: "capture-fixture", offset: 0 }, + ], + ["preview.click", { previewId: "preview-fixture", x: 1, y: 1 }], + ["preview.activate", { previewId: "preview-fixture" }], + ["preview.fill", { previewId: "preview-fixture", selector: "#input", text: "hello" }], + ["preview.select", { previewId: "preview-fixture", selector: "#select", value: "one" }], + ["preview.upload", { previewId: "preview-fixture", selector: "#file", path: "file.txt" }], + ["preview.handoff", { previewId: "preview-fixture", message: "Continue manually" }], + ["preview.scroll", { previewId: "preview-fixture", deltaX: 0, deltaY: 1 }], + ["preview.type", { previewId: "preview-fixture", text: "hello" }], + ["preview.press", { previewId: "preview-fixture", key: "Enter" }], + ["preview.close", { previewId: "preview-fixture" }], ]; for (const [name, args] of commands) { const frames = engine.receive(client.id, command(engine.seed, name, name, name, args)); diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 3a7fb13..0872e44 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -15,7 +15,7 @@ "test": "vp test run --passWithNoTests" }, "dependencies": { - "@oh-my-pi/app-wire": "file:../../vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz" + "@oh-my-pi/app-wire": "file:../../vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/protocol/test/distribution.test.ts b/packages/protocol/test/distribution.test.ts index 86732e9..30e4b51 100644 --- a/packages/protocol/test/distribution.test.ts +++ b/packages/protocol/test/distribution.test.ts @@ -132,15 +132,15 @@ describe("vendored app-wire distribution", () => { it("pins the frozen source, protocol, corpus, and tarball checksums", () => { expect(manifest).toMatchObject({ package: "@oh-my-pi/app-wire", - version: "0.5.9", + version: "0.6.0", sourceRepository: "https://github.com/lyc-aon/oh-my-pi", - sourceCommit: "5633bdd7e5f9062d1822eeeddb9311b2d942bf6f", - sourceTreeHash: "4d8794bad6fc57d86058a46dc4698fcca14263e5", - tarball: "oh-my-pi-app-wire-0.5.9.tgz", + sourceCommit: "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + sourceTreeHash: "2b8a5f697273f5044789b8ae638b6c264f9f8499", + tarball: "oh-my-pi-app-wire-0.6.0.tgz", appProtocol: "omp-app/1", - goldenCorpusSha256: "50b087a3a22bb48908718b7786eff6ce618bbd6b6123c055e40c957ef47a805c", + goldenCorpusSha256: "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1", }); - expect(manifest.createdAt).toBe("2026-07-18T09:50:41Z"); + expect(manifest.createdAt).toBe("2026-07-18T18:06:38-07:00"); expect(sha256(tarballPath)).toBe(manifest.tarballSha256); expect(goldenCorpusSha256(join(installedRoot, "fixtures", "v1"))).toBe( manifest.goldenCorpusSha256, @@ -168,9 +168,9 @@ describe("vendored app-wire distribution", () => { const lockfile = readFileSync(join(repoRoot, "pnpm-lock.yaml"), "utf8"); expect(`${protocolPackage}\n${lockfile}`).not.toContain("/home/"); expect(protocolPackage).toMatch( - /"@oh-my-pi\/app-wire": "file:\.\.\/\.\.\/vendor\/app-wire\/oh-my-pi-app-wire-0\.5\.9\.tgz"/u, + /"@oh-my-pi\/app-wire": "file:\.\.\/\.\.\/vendor\/app-wire\/oh-my-pi-app-wire-0\.6\.0\.tgz"/u, ); - expect(lockfile).toMatch(/version: file:vendor\/app-wire\/oh-my-pi-app-wire-0\.5\.9\.tgz/u); + expect(lockfile).toMatch(/version: file:vendor\/app-wire\/oh-my-pi-app-wire-0\.6\.0\.tgz/u); expect(`${protocolPackage}\n${lockfile}`).not.toMatch(/file:\/\//u); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef98899..eca2ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,8 +297,8 @@ importers: packages/protocol: dependencies: '@oh-my-pi/app-wire': - specifier: file:../../vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz - version: file:vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz + specifier: file:../../vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz + version: file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz devDependencies: '@types/node': specifier: 'catalog:' @@ -647,9 +647,9 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz': - resolution: {integrity: sha512-73QjsIWjBTtmrflnrZLWe8bjDpYwREFYe3tpEUr9F7l02fZF/K+l3EWBRLcewkvB6qgPGR3qsU8yy+bBCmc1dw==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz} - version: 0.5.9 + '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz': + resolution: {integrity: sha512-n//WxHc1cMOfKeA47sZgFR5l6mGMCEb6JX2kubw+W3AasriPHio6S85eW8x1mZdJYRjFILu3STLIYasqOMifaw==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz} + version: 0.6.0 engines: {bun: '>=1.3.14'} '@oxc-project/runtime@0.138.0': @@ -3746,7 +3746,7 @@ snapshots: '@noble/hashes@2.2.0': {} - '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.5.9.tgz': {} + '@oh-my-pi/app-wire@file:vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz': {} '@oxc-project/runtime@0.138.0': {} diff --git a/vendor/app-wire/manifest.json b/vendor/app-wire/manifest.json index 6d3745e..9bfef0a 100644 --- a/vendor/app-wire/manifest.json +++ b/vendor/app-wire/manifest.json @@ -1,12 +1,12 @@ { "package": "@oh-my-pi/app-wire", - "version": "0.5.9", + "version": "0.6.0", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "5633bdd7e5f9062d1822eeeddb9311b2d942bf6f", - "sourceTreeHash": "4d8794bad6fc57d86058a46dc4698fcca14263e5", - "tarball": "oh-my-pi-app-wire-0.5.9.tgz", - "tarballSha256": "b3a891610e919833d16302b1893831f509d264322c3869d28f17adbbff6116f0", + "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499", + "tarball": "oh-my-pi-app-wire-0.6.0.tgz", + "tarballSha256": "92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c", "appProtocol": "omp-app/1", - "goldenCorpusSha256": "50b087a3a22bb48908718b7786eff6ce618bbd6b6123c055e40c957ef47a805c", - "createdAt": "2026-07-18T09:50:41Z" + "goldenCorpusSha256": "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1", + "createdAt": "2026-07-18T18:06:38-07:00" } diff --git a/vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz b/vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..254fdfb10fefc07dfd9e781dbb45cc6ab09dcf0b GIT binary patch literal 44031 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 H02TxQhpRKm literal 0 HcmV?d00001 From e498e910d1e84c317ab4f145d586b45688359484 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:49:10 -0700 Subject: [PATCH 02/11] docs: document browser preview workspace and authority security contracts --- README.md | 1 + docs/adr/010-browser-preview-contract.md | 43 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 docs/adr/010-browser-preview-contract.md diff --git a/README.md b/README.md index 9ed3aef..7550399 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ chmod +x T4-Code-0.1.23-linux-x86_64.AppImage - **Sessions.** Browse sessions grouped by their working folder, create new ones, and switch between them. Rename, terminate a stuck runtime, archive, restore, or permanently delete a session from its menu. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice. - **Composer.** Send prompts, use slash commands (`/model`, `/compact`, `/retry`, `/review`, `/terminal`, and more), and change the session's model, thinking level, or fast mode inline. - **Panes.** Watch subagents (and cancel them), apply reviews, browse and preview files on the host, and attach to live terminals with real keyboard input and resize. +- **Browser preview.** Open session-linked browser previews to inspect page layouts, follow live navigations, and interact with the page via coordinate-mapped clicks and keyboard input. Previews use pluggable authority gates, lease-based concurrency locks, and strict opt-in security boundaries. - **Settings.** Edit host settings over the wire, with an explicit host selector when several hosts are connected; each host keeps its own drafts. Edits stage locally and only apply when the host confirms; a dropped connection never silently writes anything. - **Hosts & usage.** Run one local appserver per OMP profile, pair remote machines, and read each connected host's account usage and broker status. Everything shown is redacted host truth. - **Keyboard.** `Ctrl/Cmd+K` search, `Ctrl/Cmd+B` sidebar, `Ctrl/Cmd+1..9` session switch, `Ctrl/Cmd+,` settings. Every workflow is keyboard-operable. diff --git a/docs/adr/010-browser-preview-contract.md b/docs/adr/010-browser-preview-contract.md new file mode 100644 index 0000000..f133ee1 --- /dev/null +++ b/docs/adr/010-browser-preview-contract.md @@ -0,0 +1,43 @@ +# ADR-010: Browser Preview Workspace and Authority Security Contract + +- Status: Accepted +- Decision: Implement a dedicated session-linked Browser/App Preview Workspace. To maintain security isolation, previews must run under credential-isolated authority scopes, enforce lease-locked concurrency controls, restrict directory uploads, and handle memory bounds carefully. + +## 1. Pluggable Preview Authority & Explicit Opt-In + +To prevent automated credential extraction or session hijacking, preview hosts advertise their authority profile under one of two classifications: +1. `isolated-session` (e.g. OMP session-only browsers): credential-free, ephemeral, and restricted to the session context. +2. `authenticated-profile` (e.g. Wolfgang's real logged-in Google Chrome profile): holds active cookies, session tokens, or identity credentials. + +**Security Contract**: +- Authenticated-profile previews MUST NEVER be selected automatically. +- `choosePreview` only selects `isolated-session` previews by default. +- Authenticated-profile previews require explicit, user-initiated selection (matching a concrete `selectedPreviewId` chosen via the UI dropdown). + +## 2. Policy & Confirmation Gates + +Browser automation actions (such as clicking, typing, navigating, or upload) are privileged. +- The client runtime MUST execute a `preview.policy.check` request before sending any mutation command. +- If the host-level policy check reports `confirmationRequired: true`, the client MUST prompt the user with an explicit approval confirmation dialog. +- The command is only sent to the transport if approved by the user. + +## 3. Concurrency Lease Locks + +To prevent race conditions and multi-agent command collisions over a shared browser instance, mutations are guarded by lease locks: +- Client-side mutations are routed through the `PreviewLeaseManager`. +- Before executing click, type, fill, scroll, select, or upload, the manager acquires a lease (`preview.lease.acquire`) with a finite time-to-live (TTL). +- The lease is renewed half-way through its TTL (`preview.lease.renew`). +- On any transport disconnection, mutation failure, or timeout, the lease token is immediately invalidated and cleared from local memory to prevent subsequent execution hijacking. + +## 4. Directory and Path Confinement + +The preview upload action allows selecting local files to upload via input elements: +- File paths MUST be validated via `isProjectRelativeUploadPath(path)` prior to transmission. +- Absolute paths, windows drive letters, and parent traversal sequences (`..`) are strictly rejected. +- Upload actions are confined exclusively to project-relative assets within the workspace directory. + +## 5. Capture Object-URL Memory Management + +Screenshot captures contain base64 image data sent in chunks: +- Decoded screenshots are loaded as memory-bounded Blobs via `URL.createObjectURL(blob)`. +- To prevent browser memory leaks from accumulated images, the runtime MUST immediately revoke the active URL using `URL.revokeObjectURL(url)` whenever a preview is replaced, closed, or the session runtime is disposed. From 910ebf9f99b1380f09f29e358c9945a89e4db3fd Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:50:06 -0700 Subject: [PATCH 03/11] docs: align preview confirmation contract with challenge flow --- docs/adr/010-browser-preview-contract.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/adr/010-browser-preview-contract.md b/docs/adr/010-browser-preview-contract.md index f133ee1..fc3398f 100644 --- a/docs/adr/010-browser-preview-contract.md +++ b/docs/adr/010-browser-preview-contract.md @@ -7,7 +7,7 @@ To prevent automated credential extraction or session hijacking, preview hosts advertise their authority profile under one of two classifications: 1. `isolated-session` (e.g. OMP session-only browsers): credential-free, ephemeral, and restricted to the session context. -2. `authenticated-profile` (e.g. Wolfgang's real logged-in Google Chrome profile): holds active cookies, session tokens, or identity credentials. +2. `authenticated-profile` (e.g. a user's authenticated local browser profile): holds active cookies, session tokens, or identity credentials. **Security Contract**: - Authenticated-profile previews MUST NEVER be selected automatically. @@ -17,10 +17,10 @@ To prevent automated credential extraction or session hijacking, preview hosts a ## 2. Policy & Confirmation Gates Browser automation actions (such as clicking, typing, navigating, or upload) are privileged. -- The client runtime MUST execute a `preview.policy.check` request before sending any mutation command. -- If the host-level policy check reports `confirmationRequired: true`, the client MUST prompt the user with an explicit approval confirmation dialog. -- The command is only sent to the transport if approved by the user. - +- The client runtime executes a `preview.policy.check` pre-flight request before mutations to verify that the action is allowed by the host's policy. +- Confirmation is handled dynamically via the host-driven command-challenge flow: when a preview command is sent to the appserver, if the host requires human confirmation, it returns a `confirmation` challenge frame. +- The client projects this challenge onto the active session's confirmations list and renders a confirmation dialog. +- The mutation is only executed on the host once the user clicks "Confirm" and the client returns a corresponding `confirm` frame approving the challenge. ## 3. Concurrency Lease Locks To prevent race conditions and multi-agent command collisions over a shared browser instance, mutations are guarded by lease locks: From 530ffb8da2ae0b4454ff4b777241c67f80c05ac1 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:29:47 -0700 Subject: [PATCH 04/11] fix(preview): resolve authenticated selection gap and gate controls --- README.md | 2 +- apps/site/src/release.ts | 2 +- apps/site/test/release.test.ts | 2 +- .../src/features/preview/PreviewWorkspace.tsx | 37 +++++++++++++++---- .../web/src/features/preview/preview-model.ts | 29 +++++++++++---- .../src/features/preview/preview-runtime.ts | 17 ++++++--- .../src/features/transcript/SessionMain.tsx | 1 + apps/web/src/state/workspace-store.ts | 2 +- apps/web/test/preview-workspace.test.ts | 21 +++++++---- docs/CURRENT_RELEASE_NOTES.md | 2 +- packages/client/src/omp-client-runtime.ts | 2 +- packages/protocol/test/distribution.test.ts | 2 +- vendor/app-wire/manifest.json | 2 +- 13 files changed, 86 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 4a5bd92..94bf664 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ T4 Code needs an OMP build with desktop appserver support. For v0.1.23, use the T4 Code v0.1.23 was verified with OMP 17.0.4 built from [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), tagged [`t4code-17.0.4-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). That public integration is based on the official upstream [`v17.0.4`](https://github.com/can1357/oh-my-pi/tree/v17.0.4) tag at [`3fdd85ab`](https://github.com/can1357/oh-my-pi/commit/3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0). It adds redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.4 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.5.10 from integration commit [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), source tree `5cf488966e3c233764780d3ca7a8d8ea1e3a1f68`. -The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.5.10 from integration commit [`93f48ab6`](https://github.com/lyc-aon/oh-my-pi/commit/93f48ab62e2002b48a0dc2734de33d5328ea76d6), source tree `ea8608496731f29addc95d43ea68e44c5c42cb22`. The published v0.1.23 package remains pinned to the `d57dcd85` contract above. +T4 Code vendors `@oh-my-pi/app-wire` 0.6.0 from integration commit [`ae4b53b4`](https://github.com/lyc-aon/oh-my-pi/commit/ae4b53b416f32b200865a32ed9baabd5a4666fa4), source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`. The published v0.1.23 package remains pinned to the `d57dcd85` contract above. | Platform | Arch | Package | | -------- | --------------------- | ---------------------------------------- | diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 10dcd64..dcf8048 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -12,7 +12,7 @@ export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_R export const OMP_UPSTREAM_TAG = "v17.0.4"; export const OMP_UPSTREAM_COMMIT = "3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; -export const APP_WIRE_VERSION = "0.5.10"; +export const APP_WIRE_VERSION = "0.6.0"; export const RELEASE_TAG = "v0.1.23"; export const RELEASE_VERSION = "0.1.23"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 3a3513f..a70625e 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -69,7 +69,7 @@ describe("OMP integration contract", () => { expect(OMP_UPSTREAM_TAG).toBe("v17.0.4"); expect(OMP_UPSTREAM_COMMIT).toBe("3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0"); expect(OMP_UPSTREAM_URL).toBe("https://github.com/can1357/oh-my-pi/tree/v17.0.4"); - expect(APP_WIRE_VERSION).toBe("0.5.10"); + expect(APP_WIRE_VERSION).toBe("0.6.0"); }); }); diff --git a/apps/web/src/features/preview/PreviewWorkspace.tsx b/apps/web/src/features/preview/PreviewWorkspace.tsx index 8a15175..d2259f8 100644 --- a/apps/web/src/features/preview/PreviewWorkspace.tsx +++ b/apps/web/src/features/preview/PreviewWorkspace.tsx @@ -75,6 +75,8 @@ export function PreviewWorkspace({ [sessionProjection], ); const preview = choosePreview(previews, selectedPreviewId); + const authenticatedPreviewNeedsOptIn = + selectedPreviewId === null && preview?.authority?.kind === "authenticated-profile"; const connected = snapshot !== null && address !== null && snapshot.connections.get(address.targetId) === "connected"; const hostSupport = @@ -100,10 +102,14 @@ export function PreviewWorkspace({ const [scrollY, setScrollY] = useState("400"); useEffect(() => { - if (preview !== undefined && preview.previewId !== selectedPreviewId) { + if ( + preview !== undefined && + preview.previewId !== selectedPreviewId && + !authenticatedPreviewNeedsOptIn + ) { workspaceStore.getState().setSessionPreview(session.id, preview.previewId); } - }, [preview, selectedPreviewId, session.id]); + }, [authenticatedPreviewNeedsOptIn, preview, selectedPreviewId, session.id]); useEffect(() => { setUrl(preview?.url ?? ""); @@ -135,7 +141,18 @@ export function PreviewWorkspace({ }, [adapter, identity?.previewId, preview?.capture?.captureId, preview?.capture?.sha256]); const support = (action: PreviewAction) => - previewActionSupport(preview, action, status, hostSupport.inputSupported); + authenticatedPreviewNeedsOptIn + ? { + supported: false, + reason: "Select the authenticated preview before controlling it.", + } + : previewActionSupport( + preview, + action, + status, + hostSupport.controlSupported, + hostSupport.inputSupported, + ); const runAction = ( action: PreviewAction, _label: string, @@ -145,10 +162,10 @@ export function PreviewWorkspace({ const actionSupport = requiresPreview ? support(action) - : adapter === null || !hostSupport.supported + : adapter === null || !hostSupport.supported || !hostSupport.controlSupported ? { supported: false, - reason: hostSupport.reason ?? "This host does not advertise browser preview support.", + reason: hostSupport.reason ?? "This host does not permit browser preview control.", } : { supported: true }; if (!actionSupport.supported || adapter === null) { @@ -224,6 +241,7 @@ export function PreviewWorkspace({ disabled={ url.trim().length === 0 || !hostSupport.supported || + !hostSupport.controlSupported || (preview !== undefined && !support("navigate").supported) } onClick={() => { @@ -244,15 +262,20 @@ export function PreviewWorkspace({

Launch authority: OMP session-only authority. Authenticated profiles are never selected automatically.

- {previews.length > 1 && ( + {(previews.length > 1 || authenticatedPreviewNeedsOptIn) && (