diff --git a/README.md b/README.md index a476324..d1e63b2 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 | | -------- | --------------------- | ---------------------------------------- | @@ -89,6 +89,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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3657aac..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.10` package is packed from the public `lyc-aon/oh-my-pi` integration commit `93f48ab62e2002b48a0dc2734de33d5328ea76d6`, source tree `ea8608496731f29addc95d43ea68e44c5c42cb22`; tarball SHA-256 `d30da820ff2bb8a7efa024fc829b654a2dfaf2600688fa369abc64b053ae8ede`; golden corpus SHA-256 `63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e`. 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/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/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index 528e23c..942d4c1 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, @@ -159,6 +162,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 @@ -208,7 +220,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..a507209 --- /dev/null +++ b/apps/web/src/features/preview/PreviewWorkspace.tsx @@ -0,0 +1,488 @@ +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, useLayoutEffect, useMemo, useRef, 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, + reconcilePreviewState, + 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 selectedOptIn = useWorkspace((state) => selectSessionView(state, session.id).previewOptIn); + const selectedOptInKind = useWorkspace((state) => selectSessionView(state, session.id).previewOptInKind); + const selectedOptInAuthorityId = useWorkspace((state) => selectSessionView(state, session.id).previewOptInAuthorityId); + const scale = useWorkspace((state) => selectSessionView(state, session.id).previewScale); + const address = useMemo( + () => (snapshot === null ? null : resolveLiveSession(snapshot, session.id)), + [session.id, snapshot], + ); + 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, + selectedOptIn, + selectedOptInKind, + selectedOptInAuthorityId, + ); + const connected = + snapshot !== null && address !== null && snapshot.connections.get(address.targetId) === "connected"; + const adapter = useMemo( + () => (controller === null || address === null ? null : new PreviewDesktopAdapter(controller, address)), + [ + address?.hostId, + address?.sessionId, + address?.targetId, + connected, + controller, + preview?.authority?.id, + preview?.authority?.kind, + preview?.previewId, + ], + ); + 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 operationLifecycleKey = [ + address?.targetId ?? "", + address?.hostId ?? "", + address?.sessionId ?? "", + preview?.previewId ?? "", + preview?.authority?.kind ?? "", + preview?.authority?.id ?? "", + connected ? "connected" : "disconnected", + ].join("\u0000"); + const operationGeneration = useRef(0); + 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"); + + + useLayoutEffect(() => { + operationGeneration.current += 1; + return () => { + operationGeneration.current += 1; + }; + }, [operationLifecycleKey]); + + useEffect(() => { + reconcilePreviewState({ selectedPreviewId, previews }, () => { + workspaceStore.getState().setSessionPreview(session.id, { + previewId: null, + optIn: false, + }); + }); + }, [selectedPreviewId, previews, 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); + adapter.releaseCapture(identity); + }; + }, [adapter, identity?.previewId, preview?.capture?.captureId, preview?.capture?.sha256]); + + const support = (action: PreviewAction) => + previewActionSupport( + preview, + action, + status, + hostSupport.controlSupported, + hostSupport.inputSupported, + ); + const runAction = ( + action: PreviewAction, + _label: string, + operation: () => Promise, + requiresPreview = true, + ) => { + const actionSupport = + requiresPreview + ? support(action) + : adapter === null || !hostSupport.supported || !hostSupport.controlSupported + ? { + supported: false, + reason: + hostSupport.reason ?? + "This host does not permit browser preview control.", + } + : { supported: true }; + if (!actionSupport.supported || adapter === null) { + setError(actionSupport.reason ?? "Preview actions are unavailable."); + return; + } + setError(undefined); + const generation = operationGeneration.current; + const requestAdapter = adapter; + void requestAdapter + .policy(action, identity, action === "navigate" ? url.trim() : undefined) + .then((policy) => { + if (generation !== operationGeneration.current) return; + if (!policy.allowed) { + setError("This preview action is not allowed by the host."); + return; + } + return operation(); + }) + .catch((cause: unknown) => { + if (generation === operationGeneration.current) { + 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 || (previews.length === 1 && preview === undefined)) && ( + + )} +
+ +
+ {([ + ["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..b2446be --- /dev/null +++ b/apps/web/src/features/preview/preview-model.ts @@ -0,0 +1,225 @@ +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 controlSupported: 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, + controlSupported: false, + inputSupported: false, + reason: "This host does not advertise browser preview control.", + }; + } + if (!host.grantedCapabilities.includes("preview.read")) { + return { + supported: false, + controlSupported: false, + inputSupported: false, + reason: "This host does not permit browser preview reads.", + }; + } + const controlSupported = host.grantedCapabilities.includes("preview.control"); + return { + supported: true, + controlSupported, + inputSupported: controlSupported && 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 !== "fresh") { + return "cached"; + } + return options.preview.state ?? "ready"; +} + +export function previewActionSupport( + preview: PreviewProjection | undefined, + action: PreviewAction, + status: PreviewWorkspaceStatus, + controlSupported: boolean, + 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 preview state is current." }; + } + if (!controlSupported) { + return { supported: false, reason: "This host does not permit browser preview control." }; + } + if (status === "empty" || preview === undefined) { + return { supported: false, reason: "Launch a preview before using this action." }; + } + if (!controlSupported) { + return { supported: false, reason: "This host does not permit browser preview control." }; + } + 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, + optIn: boolean, + optInKind: string | null, + optInAuthorityId: string | null, +): PreviewProjection | undefined { + if (selectedPreviewId !== null) { + const selected = previews.find((preview) => preview.previewId === selectedPreviewId); + if (selected !== undefined) { + const isDefaultSafe = + selected.authority?.kind === "isolated-session" && + selected.authority.requiresExplicitOptIn === false; + if (isDefaultSafe) { + return selected; + } + const currentKind = selected.authority?.kind ?? null; + const currentId = selected.authority?.id ?? null; + if (optIn && currentKind === optInKind && currentId === optInAuthorityId) { + return selected; + } + } + } + return previews.find( + (preview) => + preview.authority?.kind === "isolated-session" && + preview.authority.requiresExplicitOptIn === false, + ); +} + +export interface PreviewStateReconciliation { + readonly selectedPreviewId: string | null; + readonly previews: readonly PreviewProjection[]; +} + +export function reconcilePreviewState( + state: PreviewStateReconciliation, + clearPreview: () => void, +): void { + if ( + state.selectedPreviewId !== null && + !state.previews.some((p) => p.previewId === state.selectedPreviewId) + ) { + clearPreview(); + } + +} + +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..094b728 --- /dev/null +++ b/apps/web/src/features/preview/preview-runtime.ts @@ -0,0 +1,199 @@ +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; + private disposed = false; + + + 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, + ttlMs === undefined ? { leaseId: identity.leaseId } : { leaseId: identity.leaseId, ttlMs }, + ), + ), + previewLeaseRelease: async (identity) => + this.leaseResponse(await this.command("preview.lease.release", identity, { leaseId: identity.leaseId })), + }); + } + + async launch(url: string, authorityId = "omp-session"): Promise { + this.assertActive(); + await this.command("preview.launch", undefined, { url, authorityId }); + } + + async policy( + action: PreviewAction, + identity: PreviewIdentity | undefined, + url?: string, + ): Promise { + this.assertActive(); + 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 { + this.assertActive(); + 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 { + this.assertActive(); + 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 { + this.assertActive(); + const result = await this.controller.confirm({ + targetId: this.address.targetId, + ...challenge, + decision, + }); + if (!result.accepted) throw new Error("The host rejected the preview confirmation."); + } + + releaseCapture(identity: PreviewIdentity): void { + this.captures.release(identity); + } + + async release(identity: PreviewIdentity): Promise { + this.captures.release(identity); + await this.leases.release(identity); + } + + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.captures.dispose(); + await this.leases.releaseAll(); + } + + private assertActive(): void { + if (this.disposed) throw new Error("Preview workspace is no longer active."); + } + + 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 68568c7..e935861 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 { @@ -51,6 +54,11 @@ export interface SessionMainProps { readonly onOpenHostHealth: () => void; } +/** 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 ( @@ -341,8 +349,27 @@ export function SessionControlBanner({ export function SessionMain({ onOpenHostHealth, 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) => @@ -356,8 +383,11 @@ export function SessionMain({ onOpenHostHealth, 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 @@ -465,6 +495,21 @@ export function SessionMain({ onOpenHostHealth, session }: SessionMainProps) { Archived · read-only. Restore this session before continuing work. )} + {previews.length > 0 && ( +
+ +
+ )} {snapshot.providerTransport !== null && ( )} + {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 cd14d40..fd4eb19 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 { LiveAttentionInbox } from "./features/attention/index.ts"; import { SettingsWorkspace } from "./features/settings/index.ts"; import { LiveSettingsScreen } from "./features/settings/LiveSettingsScreen.tsx"; @@ -35,6 +36,7 @@ import { type ProfilesPort, type TargetsStoreApi, } from "./features/targets/targets-store.ts"; +import type { WorkspaceProject, WorkspaceSession } from "./lib/workspace-data.ts"; import { applySessionRoutePendingGrace, createSessionRouteActivationGate, @@ -89,8 +91,17 @@ const inboxRoute = createRoute({ component: LiveAttentionInbox, }); -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); @@ -113,18 +124,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); @@ -168,7 +172,13 @@ function SessionRoute() { return ; } if (decision.kind === "redirect-session") { - return ( + return previewRoute ? ( + + ) : ( ); } @@ -220,14 +230,33 @@ function SessionRoute() { ); } + return children(session, project, nowMs); +} + +function SessionRoute() { + const navigate = useNavigate(); + const { sessionId } = useParams({ from: "/sessions/$sessionId" }); return ( - void navigate({ to: "/hosts" })} - project={project} - session={session} - /> + + {(session, project, nowMs) => ( + void navigate({ to: "/hosts" })} + project={project} + session={session} + /> + )} + + ); +} + +function PreviewRoute() { + const { sessionId } = useParams({ from: "/sessions/$sessionId/preview" }); + return ( + + {(session, project) => } + ); } @@ -237,6 +266,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); @@ -405,6 +440,7 @@ const routeTree = rootRoute.addChildren([ indexRoute, inboxRoute, sessionRoute, + previewRoute, agentViewRoute, settingsRoute, hostsRoute, diff --git a/apps/web/src/state/workspace-store.ts b/apps/web/src/state/workspace-store.ts index 331166e..fb7cc19 100644 --- a/apps/web/src/state/workspace-store.ts +++ b/apps/web/src/state/workspace-store.ts @@ -22,6 +22,14 @@ 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"; + +export interface SessionPreviewSelection { + readonly previewId: string | null; + readonly optInKind?: string | null; + readonly optInAuthorityId?: string | null; + readonly optIn: boolean; +} /** Per-session view continuity: everything restored on A→B→A switching. */ export interface SessionViewState { @@ -33,6 +41,12 @@ 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 previewOptIn: boolean; + readonly previewOptInKind: string | null; + readonly previewOptInAuthorityId: string | null; + readonly previewScale: PreviewScaleMode; } export const DEFAULT_SESSION_VIEW: SessionViewState = { @@ -42,6 +56,11 @@ export const DEFAULT_SESSION_VIEW: SessionViewState = { paneOpen: false, paneWidth: RIGHT_PANE_WIDTH.defaultWidth, terminalDrawerOpen: false, + previewId: null, + previewOptIn: false, + previewOptInKind: null, + previewOptInAuthorityId: null, + previewScale: "fit", }; interface PersistedWorkspaceState { @@ -101,6 +120,11 @@ export interface WorkspaceActions { setPaneOpen(sessionId: string, open: boolean): void; setPaneWidth(sessionId: string, width: number): void; setTerminalDrawerOpen(sessionId: string, open: boolean): void; + setSessionPreview( + sessionId: string, + selection: SessionPreviewSelection, + ): void; + setSessionPreviewScale(sessionId: string, scale: PreviewScaleMode): void; } export type WorkspaceStore = WorkspaceState & WorkspaceActions; @@ -179,6 +203,27 @@ 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 && + !/\p{Cc}/u.test(view.previewId) + ? view.previewId + : null, + previewOptInKind: + typeof view.previewOptInKind === "string" && + view.previewOptInKind.length > 0 && + view.previewOptInKind.length <= 256 + ? view.previewOptInKind + : null, + previewOptInAuthorityId: + typeof view.previewOptInAuthorityId === "string" && + view.previewOptInAuthorityId.length > 0 && + view.previewOptInAuthorityId.length <= 256 + ? view.previewOptInAuthorityId + : null, + previewOptIn: view.previewOptIn === true, + previewScale: view.previewScale === "actual" ? "actual" : "fit", }; } @@ -374,14 +419,25 @@ export function createWorkspaceStore(options: CreateWorkspaceStoreOptions): Work }), setPaneOpen: (sessionId, open) => set((state) => updateSessionView(state, sessionId, { paneOpen: open })), - setPaneWidth: (sessionId, width) => + setPaneWidth: (sessionId, paneWidth) => set((state) => updateSessionView(state, sessionId, { - paneWidth: clampWidth(width, RIGHT_PANE_WIDTH), + paneWidth: clampWidth(paneWidth, RIGHT_PANE_WIDTH), }), ), setTerminalDrawerOpen: (sessionId, open) => set((state) => updateSessionView(state, sessionId, { terminalDrawerOpen: open })), + setSessionPreview: (sessionId, selection) => + set((state) => + updateSessionView(state, sessionId, { + previewId: selection.previewId, + previewOptInKind: selection.optInKind ?? null, + previewOptInAuthorityId: selection.optInAuthorityId ?? null, + previewOptIn: selection.optIn, + }), + ), + 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..d851943 --- /dev/null +++ b/apps/web/test/preview-workspace.test.ts @@ -0,0 +1,391 @@ +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({ freshness: "catching-up" }), 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 previews that require explicit consent", () => { + 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 } }); + const unknownAuth = preview({ previewId: "unknown" }); + const isolatedOptIn = preview({ previewId: "isolated-opt-in", authority: { id: "omp-session", label: "Session", kind: "isolated-session", requiresExplicitOptIn: true } }); + + expect(choosePreview([authenticated, isolated], null, false, null, null)?.previewId).toBe("isolated"); + expect(choosePreview([authenticated], null, false, null, null)).toBeUndefined(); + expect(choosePreview([unknownAuth], null, false, null, null)).toBeUndefined(); + expect(choosePreview([isolatedOptIn], null, false, null, null)).toBeUndefined(); + expect(choosePreview([authenticated, isolated], "authenticated", true, "authenticated-profile", "auth")?.previewId).toBe("authenticated"); + expect(defaultLaunchAuthority()).toBe("omp-session"); + }); + + it("invalidates user selection if the preview transitions to a different authority or trust class", () => { + const isolated = preview({ previewId: "p1", authority: { id: "omp-session", label: "Session", kind: "isolated-session", requiresExplicitOptIn: false } }); + const authenticatedA = preview({ previewId: "p1", authority: { id: "auth-a", label: "Profile A", kind: "authenticated-profile", requiresExplicitOptIn: true } }); + const authenticatedB = preview({ previewId: "p1", authority: { id: "auth-b", label: "Profile B", kind: "authenticated-profile", requiresExplicitOptIn: true } }); + const unknownAuth = preview({ previewId: "p1" }); + const isolatedOptInA = preview({ previewId: "p1", authority: { id: "auth-x", label: "Opt-in X", kind: "isolated-session", requiresExplicitOptIn: true } }); + const isolatedOptInB = preview({ previewId: "p1", authority: { id: "auth-y", label: "Opt-in Y", kind: "isolated-session", requiresExplicitOptIn: true } }); + + expect(choosePreview([isolated], "p1", false, null, null)?.previewId).toBe("p1"); + + expect( + choosePreview([authenticatedA], "p1", true, "isolated-session", "omp-session"), + ).toBeUndefined(); + expect( + choosePreview([authenticatedA], "p1", true, "authenticated-profile", "auth-a")?.previewId, + ).toBe("p1"); + expect( + choosePreview([authenticatedB], "p1", true, "authenticated-profile", "auth-a"), + ).toBeUndefined(); + + expect(choosePreview([unknownAuth], "p1", false, null, null)).toBeUndefined(); + expect(choosePreview([unknownAuth], "p1", true, null, null)?.previewId).toBe("p1"); + expect( + choosePreview([isolatedOptInA], "p1", true, null, null), + ).toBeUndefined(); + expect( + choosePreview([isolatedOptInA], "p1", true, "isolated-session", "auth-x")?.previewId, + ).toBe("p1"); + expect( + choosePreview([isolatedOptInB], "p1", true, "isolated-session", "auth-x"), + ).toBeUndefined(); + }); + + it("clears user selection when the selected preview disappears, preventing subsequent auto-authorization on reappearance", () => { + const authenticatedA = preview({ previewId: "p1", authority: { id: "auth-a", label: "Profile A", kind: "authenticated-profile", requiresExplicitOptIn: true } }); + + expect( + choosePreview([authenticatedA], "p1", true, "authenticated-profile", "auth-a")?.previewId, + ).toBe("p1"); + + expect( + choosePreview([], "p1", true, "authenticated-profile", "auth-a"), + ).toBeUndefined(); + expect( + choosePreview([authenticatedA], "p1", true, "authenticated-profile", "auth-a")?.previewId, + ).toBe("p1"); + expect(choosePreview([authenticatedA], null, false, null, null)).toBeUndefined(); + }); + + 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, true)).toEqual({ + supported: false, + reason: "This host does not advertise click for this preview.", + }); + expect(previewActionSupport(withoutActions, "navigate", "ready", true, true)).toEqual({ + supported: false, + reason: "This host does not advertise navigate for this preview.", + }); + expect(previewActionSupport(current, "navigate", "cached", true, true)).toEqual({ + supported: false, + reason: "Preview actions are unavailable until preview state is current.", + }); + expect(previewActionSupport(current, "navigate", "ready", false, true)).toEqual({ + supported: false, + reason: "This host does not permit browser preview control.", + }); + expect( + displayedToNativeCoordinate({ x: 50, y: 25 }, { width: 100, height: 50 }, { width: 1000, height: 500 }), + ).toEqual({ x: 500, y: 250 }); + }); + + it("rejects absolute, drive-relative, 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("C:secret.txt")).toBe(false); + expect(isProjectRelativeUploadPath("D:secret.txt")).toBe(false); + expect(isProjectRelativeUploadPath("../image.png")).toBe(false); + }); + + it("distinguishes read-only, control, and input grants", () => { + expect(previewHostSupport(undefined)).toEqual({ + supported: false, + controlSupported: false, + inputSupported: false, + reason: "This host does not advertise browser preview control.", + }); + expect( + previewHostSupport({ + grantedFeatures: ["preview.control"], + grantedCapabilities: ["preview.read"], + }), + ).toEqual({ supported: true, controlSupported: false, inputSupported: false }); + expect( + previewHostSupport({ + grantedFeatures: ["preview.control"], + grantedCapabilities: ["preview.read", "preview.control", "preview.input"], + }), + ).toEqual({ supported: true, controlSupported: 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("acquires a fresh lease after reconnect recreates the adapter", async () => { + let acquired = 0; + const mutationLeases: unknown[] = []; + const command = vi.fn( + async (_targetId: string, intent: { command: string; args: Record }) => { + if (intent.command === "preview.lease.acquire") { + acquired += 1; + return accepted({ + previewId: identity.previewId, + leaseId: `lease-${acquired}`, + expiresAt: Date.now() + 30_000, + }); + } + if (intent.command === "preview.navigate") { + mutationLeases.push(intent.args.leaseId); + } + return accepted({}); + }, + ); + const controller = { command, confirm: vi.fn() } as unknown as DesktopRuntimeController; + const first = new PreviewDesktopAdapter(controller, address); + await first.mutate("navigate", identity); + await first.dispose(); + + const reconnected = new PreviewDesktopAdapter(controller, address); + await reconnected.mutate("navigate", identity); + + expect(acquired).toBe(2); + expect(mutationLeases).toEqual(["lease-1", "lease-2"]); + await reconnected.dispose(); + }); + 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("blocks a deferred policy continuation after the adapter lifecycle ends", async () => { + const policy = Promise.withResolvers(); + const commands: string[] = []; + const command = vi.fn( + async (_targetId: string, intent: { command: string }): Promise => { + commands.push(intent.command); + if (intent.command === "preview.policy.check") return policy.promise; + 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, + ); + + const pendingPolicy = adapter.policy("navigate", identity, "https://example.test"); + await Promise.resolve(); + await adapter.dispose(); + policy.resolve(accepted({ allowed: true })); + + await expect(pendingPolicy).resolves.toEqual({ allowed: true }); + await expect( + adapter.mutate("navigate", identity, { url: "https://example.test" }), + ).rejects.toThrow("no longer active"); + expect(commands).toEqual(["preview.policy.check"]); + }); + it("keeps cooperative leases while replacing capture object URLs", 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" }); + command.mockClear(); + adapter.releaseCapture(identity); + + expect(command).not.toHaveBeenCalled(); + await adapter.dispose(); + }); + + 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 a6ff533..bed7a68 100644 --- a/apps/web/test/workspace-store.test.ts +++ b/apps/web/test/workspace-store.test.ts @@ -23,7 +23,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(); @@ -33,12 +33,20 @@ describe("session continuity (A→B→A)", () => { s().togglePaneFamily("A", "review"); s().setPaneWidth("A", 500); s().setTerminalDrawerOpen("A", true); + s().setSessionPreview("A", { + previewId: "preview-a", + optInKind: "authenticated-profile", + optInAuthorityId: "auth-a", + optIn: true, + }); + 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", { previewId: "preview-b", optIn: false }); s().activateSession("A", "2026-07-11T10:02:00Z"); const viewA = selectSessionView(s(), "A"); @@ -48,6 +56,11 @@ 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.previewOptIn).toBe(true); + expect(viewA.previewOptInKind).toBe("authenticated-profile"); + expect(viewA.previewOptInAuthorityId).toBe("auth-a"); + expect(viewA.previewScale).toBe("actual"); const viewB = selectSessionView(s(), "B"); expect(viewB.draft).toBe("other draft"); @@ -55,6 +68,9 @@ 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.previewOptIn).toBe(false); + expect(viewB.previewScale).toBe("fit"); }); it("returns defaults for sessions never touched", () => { @@ -128,6 +144,13 @@ describe("persistence", () => { first.getState().setRailWidth(300); first.getState().setTheme("dark"); first.getState().setEmptyProjectDismissed("host/project", true); + first.getState().setSessionPreview("A", { + previewId: "preview-a", + optInKind: "authenticated-profile", + optInAuthorityId: "auth-a", + optIn: true, + }); + first.getState().setSessionPreviewScale("A", "actual"); first.getState().markAttentionOutcomeSeen("A", "outcome-1"); first.getState().setPaletteOpen(true); // ephemeral, must not persist @@ -135,6 +158,11 @@ describe("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").previewOptIn).toBe(true); + expect(selectSessionView(state, "A").previewOptInKind).toBe("authenticated-profile"); + expect(selectSessionView(state, "A").previewOptInAuthorityId).toBe("auth-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 }); @@ -183,6 +211,30 @@ describe("persistence", () => { paneOpen: true, paneWidth: 400, }); + expect(parsed?.sessionViewById.A).toMatchObject({ + previewId: null, + previewScale: "fit", + }); + }); + + it("does not grant consent to persisted preview selections from before the opt-in marker", () => { + const parsed = parsePersistedWorkspace({ + version: WORKSPACE_STATE_VERSION, + sessionViewById: { + A: { + previewId: "legacy-preview", + previewOptInKind: null, + previewOptInAuthorityId: null, + }, + }, + }); + + expect(parsed?.sessionViewById.A).toMatchObject({ + previewId: "legacy-preview", + previewOptIn: false, + previewOptInKind: null, + previewOptInAuthorityId: null, + }); }); it("rejects wrong versions and non-objects", () => { @@ -207,7 +259,14 @@ describe("persistence", () => { invalid: 42, }, 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, }, }); @@ -225,6 +284,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 aa260d7..f00312f 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.10", + "version": "0.6.0", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "93f48ab62e2002b48a0dc2734de33d5328ea76d6", - "sourceTreeHash": "ea8608496731f29addc95d43ea68e44c5c42cb22", - "tarball": "vendor/app-wire/oh-my-pi-app-wire-0.5.10.tgz", - "tarballSha256": "d30da820ff2bb8a7efa024fc829b654a2dfaf2600688fa369abc64b053ae8ede", - "goldenCorpusSha256": "63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e" + "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.10", + "version": "0.6.0", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "d57dcd855006c673d8d530237d474fe5ba5645c4", - "sourceTreeHash": "5cf488966e3c233764780d3ca7a8d8ea1e3a1f68" + "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499" }, "publishedRuntime": { "package": "omp", diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index b11790d..797fda8 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -20,7 +20,7 @@ A completed assistant response has **Read response aloud** when the device provi ## Runtime provenance -T4 Code v0.1.23 vendors app-wire 0.5.10 from integration commit [d57dcd85](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), source tree `5cf488966e3c233764780d3ca7a8d8ea1e3a1f68`. The client contract remains `omp-app/1`. +T4 Code v0.1.23 vendors app-wire 0.6.0 from integration commit [ae4b53b4](https://github.com/lyc-aon/oh-my-pi/commit/ae4b53b416f32b200865a32ed9baabd5a4666fa4), source tree `2b8a5f697273f5044789b8ae638b6c264f9f8499`. The client contract remains `omp-app/1`. The matching OMP 17.0.4 runtime is built from commit [d57dcd85](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4) and tagged [t4code-17.0.4-appserver-5](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). 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, and the cooperative `/continue-in-t4` handoff. diff --git a/docs/adr/010-browser-preview-contract.md b/docs/adr/010-browser-preview-contract.md new file mode 100644 index 0000000..fc3398f --- /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. 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. +- `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 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: +- 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. diff --git a/e2e/remote-app.spec.ts b/e2e/remote-app.spec.ts index b57c4aa..8002a85 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, }) => { @@ -1441,3 +1440,48 @@ 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, +}) => { + const previewFixture = new FixtureProcess("preview-v1"); + let previewWeb: BuiltWebServer | undefined; + try { + await previewFixture.start(); + previewWeb = new BuiltWebServer(previewFixture.wsUrl); + await previewWeb.start(); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(previewWeb.url, { waitUntil: "domcontentloaded" }); + await expect(page.getByText(CONNECTED_COPY, { exact: true })).toBeVisible(); + const session = page.locator('[data-session-row="host-preview/session-preview"]'); + await expect(session).toBeVisible(); + await session.click(); + + 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); + } finally { + await previewWeb?.stop(); + await previewFixture.stop(); + } +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 90398dc..fc12856 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..49f9c8d 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,8 @@ export class OmpClient { async close(): Promise { if (this.stateValue === "closed") return; + if (this.stateValue === "ready") await this.previewLeaseManager.releaseAll(); + this.previewLeaseManager.invalidateAll(); this.closedByUser = true; this.fatalError = undefined; this.clearInbound(); @@ -295,20 +419,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 +741,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 +759,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 +849,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 +873,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 +902,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 +926,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 +967,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 +986,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 +1003,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 +1017,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 +1057,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 +1069,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,10 +1105,16 @@ 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 { + this.previewLeaseManager.invalidateAll(); if (this.closedByUser || isTerminalState(this.stateValue)) return; const helloRejected = this.stateValue === "handshaking" && code === 1008 && reason?.trim() === "invalid frame"; @@ -624,9 +1148,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 +1179,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.read") + ) + 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 +1267,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 +1292,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 +1300,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 +1309,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..f97439e --- /dev/null +++ b/packages/client/src/preview.ts @@ -0,0 +1,514 @@ +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; + private generation = 0; + + 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 generation = this.generation; + 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"); + if (generation !== this.generation) { + await this.releaseLease(identity, lease.leaseId); + throw new Error("preview lease acquire invalidated"); + } + this.leases.set(key, Object.freeze({ identity: { ...identity }, ...lease, ttlMs })); + return lease.leaseId; + } + + async renew(identity: PreviewIdentity, ttlMs = this.defaultTtlMs): Promise { + const generation = this.generation; + 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"); + if (generation !== this.generation) { + await this.releaseLease(identity, lease.leaseId); + throw new Error("preview lease renew invalidated"); + } + 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 generation = this.generation; + const leaseId = await this.ensure(identity, ttlMs); + if (generation !== this.generation) throw new Error("preview lease mutation invalidated"); + try { + return await operation(leaseId); + } catch (error) { + 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)); + } + + invalidateAll(): void { + this.generation += 1; + this.leases.clear(); + } + + 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 { + this.generation += 1; + const leases = [...this.leases.values()]; + this.leases.clear(); + await Promise.all( + leases.map((lease) => this.releaseLease(lease.identity, lease.leaseId)), + ); + } + + private async releaseLease(identity: PreviewIdentity, leaseId: string): Promise { + try { + await this.client.previewLeaseRelease({ ...identity, leaseId }); + } catch { + // Teardown must not retain or resurrect a lease after a transport loss. + } + } +} 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 2f26463..4528aec 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, }); @@ -660,6 +727,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; } @@ -750,7 +986,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" || @@ -1129,6 +1368,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( @@ -1322,12 +1634,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]) => @@ -1335,13 +1641,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..2eaac8b 100644 --- a/packages/client/test/client-reconnect.test.ts +++ b/packages/client/test/client-reconnect.test.ts @@ -917,4 +917,113 @@ 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.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.read", 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..abb1127 --- /dev/null +++ b/packages/client/test/preview-lease.test.ts @@ -0,0 +1,123 @@ +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); + }); + + it("fences and releases a lease acquired after teardown", async () => { + const acquire = Promise.withResolvers<{ + ok: true; + result: { previewId: string; leaseId: string; expiresAt: number }; + }>(); + const released: string[] = []; + let mutations = 0; + const manager = new PreviewLeaseManager({ + previewLeaseAcquire: async () => acquire.promise, + previewLeaseRenew: async () => ({ ok: true, result: {} }), + previewLeaseRelease: async (identity) => { + released.push(identity.leaseId ?? ""); + return { ok: true }; + }, + }); + const identity: PreviewIdentity = { + hostId: "host", + sessionId: "session", + previewId: "preview", + }; + + const pending = manager.mutate(identity, async () => { + mutations += 1; + }); + await Promise.resolve(); + await manager.releaseAll(); + acquire.resolve({ + ok: true, + result: { + previewId: identity.previewId, + leaseId: "late-lease", + expiresAt: Date.now() + 30_000, + }, + }); + + await expect(pending).rejects.toThrow("preview lease acquire invalidated"); + expect(mutations).toBe(0); + expect(released).toEqual(["late-lease"]); + }); +}); diff --git a/packages/client/test/projection.test.ts b/packages/client/test/projection.test.ts index 9171879..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, @@ -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 640ac81..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.10.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 ab7a420..9334b26 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.10", + version: "0.6.0", sourceRepository: "https://github.com/lyc-aon/oh-my-pi", - sourceCommit: "93f48ab62e2002b48a0dc2734de33d5328ea76d6", - sourceTreeHash: "ea8608496731f29addc95d43ea68e44c5c42cb22", - tarball: "oh-my-pi-app-wire-0.5.10.tgz", + sourceCommit: "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + sourceTreeHash: "2b8a5f697273f5044789b8ae638b6c264f9f8499", + tarball: "oh-my-pi-app-wire-0.6.0.tgz", appProtocol: "omp-app/1", - goldenCorpusSha256: "63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e", + goldenCorpusSha256: "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1", }); - expect(manifest.createdAt).toBe("2026-07-19T01:54:54Z"); + expect(manifest.createdAt).toBe("2026-07-19T01:06:38Z"); 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\.10\.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\.10\.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/packages/protocol/test/import-boundary.test.ts b/packages/protocol/test/import-boundary.test.ts index 196acd2..52832d7 100644 --- a/packages/protocol/test/import-boundary.test.ts +++ b/packages/protocol/test/import-boundary.test.ts @@ -10,7 +10,7 @@ const scannedRoots = [join(repoRoot, "apps"), join(repoRoot, "packages")]; function sourceFiles(directory: string): string[] { const files: string[] = []; for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.name === "node_modules") continue; + if (["node_modules", "dist", "dist-electron", ".next", ".turbo", "build", ".artifacts"].includes(entry.name)) continue; const absolute = join(directory, entry.name); if (entry.isDirectory()) files.push(...sourceFiles(absolute)); else if (entry.isFile() && /\.[cm]?[jt]sx?$/u.test(entry.name)) files.push(absolute); @@ -19,9 +19,11 @@ function sourceFiles(directory: string): string[] { } function rawAppWireImports(path: string): string[] { + const content = readFileSync(path, "utf8"); + if (!content.includes("@oh-my-pi/app-wire")) return []; const source = ts.createSourceFile( path, - readFileSync(path, "utf8"), + content, ts.ScriptTarget.Latest, false, path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd06bb1..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.10.tgz - version: file:vendor/app-wire/oh-my-pi-app-wire-0.5.10.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.10.tgz': - resolution: {integrity: sha512-OJ3JRW1saNkC8lvpmfsa6R/gGJqs8JWc7dkmmTo+pbiHt/BNxOG6SFSXNR+/RK6WaKLL3vl+KUnK8zpHc+Bjmg==, tarball: file:vendor/app-wire/oh-my-pi-app-wire-0.5.10.tgz} - version: 0.5.10 + '@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.10.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/provenance/t3code/imports/f1-shell-20260711.json b/provenance/t3code/imports/f1-shell-20260711.json index b07346d..d7f901c 100644 --- a/provenance/t3code/imports/f1-shell-20260711.json +++ b/provenance/t3code/imports/f1-shell-20260711.json @@ -9,7 +9,7 @@ "sourceBlobSha": "4a97f0542b4c95e96eae835a8d01db56e7156f1a;70d163306cca5b32d32319359c8ecc9a68c3dbf8", "targetPath": "apps/web/src/state/workspace-store.ts", "classification": "adapted", - "checksum": "sha256:f017b34e61ad96502c451f36e8afc0ce4a60ec5a7a33928117d296c10f8e0b52" + "checksum": "sha256:114958f57d4814ff4c9a9a40a9c5455f4b81df5678a58fa1477ba5a0cc6e5ada" }, { "sourcePath": "apps/web/src/hooks/useTheme.ts", diff --git a/scripts/check-provenance.mjs b/scripts/check-provenance.mjs index d2c36b6..3849e9f 100644 --- a/scripts/check-provenance.mjs +++ b/scripts/check-provenance.mjs @@ -63,4 +63,4 @@ export async function checkProvenance(root = process.cwd()) { export function formatReport(result) { return `Checked ${result.checked} provenance manifest${result.checked === 1 ? "" : "s"}; ${result.failures.length} failure${result.failures.length === 1 ? "" : "s"}.${result.failures.length ? `\n${result.failures.join("\n")}` : ""}`; } -if (import.meta.url === `file://${process.argv[1]}`) { const result = await checkProvenance(process.cwd()); console.log(formatReport(result)); if (result.failures.length) process.exitCode = 1; } +if (import.meta.main) { const result = await checkProvenance(process.cwd()); console.log(formatReport(result)); if (result.failures.length) process.exitCode = 1; } diff --git a/scripts/check-provenance.test.mjs b/scripts/check-provenance.test.mjs index 7aa6f7e..6163817 100644 --- a/scripts/check-provenance.test.mjs +++ b/scripts/check-provenance.test.mjs @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createHash } from "node:crypto"; @@ -24,3 +25,27 @@ test("reports stale, malformed, traversal, missing, duplicate with stable diagno const first = await checkProvenance(root); const second = await checkProvenance(root); assert.deepEqual(first.failures, second.failures); assert(first.failures.some((line) => line.includes("checksum mismatch") || line.includes("targetPath"))); }); +test("entrypoint executes directly when path has spaces", async () => { + const spaceRoot = path.join(os.tmpdir(), "check-prov space-" + Math.random().toString(36).slice(2)); + await mkdir(path.join(spaceRoot, "provenance/t3code/imports"), { recursive: true }); + await mkdir(spaceRoot, { recursive: true }); + const scriptDest = path.join(spaceRoot, "check-provenance.mjs"); + const scriptContent = await readFile(new URL("./check-provenance.mjs", import.meta.url), "utf8"); + await writeFile(scriptDest, scriptContent); + + let error = null; + try { + // Run the script directly with no manifests in spaceRoot. + // It should run and fail (exit code 1) because no manifests exist, + // which proves it executed rather than silently exiting with code 0. + execFileSync(process.execPath, [scriptDest], { cwd: spaceRoot, stdio: "pipe" }); + } catch (err) { + error = err; + } + + assert.ok(error, "Expected direct execution to fail due to missing manifests"); + assert.equal(error.status, 1, "Expected exit code 1"); + const stderr = error.stderr.toString() + error.stdout.toString(); + assert.ok(stderr.includes("no manifests found"), "Expected 'no manifests found' error in output"); +}); + diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 3e8c4b2..5d3d018 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -337,7 +337,7 @@ test("rejects published app-wire provenance drift until release surfaces agree", test("rejects drift between the compatibility matrix and vendored app-wire manifest", () => { const drifted = changed("vendor/app-wire/manifest.json", (text) => text.replace( - '"sourceTreeHash": "ea8608496731f29addc95d43ea68e44c5c42cb22"', + '"sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499"', '"sourceTreeHash": "0000000000000000000000000000000000000000"', ), ); @@ -350,7 +350,7 @@ test("rejects drift between the compatibility matrix and vendored app-wire manif test("rejects a stale app-wire third-party notice", () => { const drifted = changed("THIRD_PARTY_NOTICES.md", (text) => - text.replace("@oh-my-pi/app-wire@0.5.10", "@oh-my-pi/app-wire@0.5.8"), + text.replace("@oh-my-pi/app-wire@0.6.0", "@oh-my-pi/app-wire@0.5.8"), ); assert.ok( collectReleaseConsistencyErrors(drifted).some((error) => diff --git a/vendor/app-wire/manifest.json b/vendor/app-wire/manifest.json index 45682a0..73d34f8 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.10", + "version": "0.6.0", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "93f48ab62e2002b48a0dc2734de33d5328ea76d6", - "sourceTreeHash": "ea8608496731f29addc95d43ea68e44c5c42cb22", - "tarball": "oh-my-pi-app-wire-0.5.10.tgz", - "tarballSha256": "d30da820ff2bb8a7efa024fc829b654a2dfaf2600688fa369abc64b053ae8ede", + "sourceCommit": "ae4b53b416f32b200865a32ed9baabd5a4666fa4", + "sourceTreeHash": "2b8a5f697273f5044789b8ae638b6c264f9f8499", + "tarball": "oh-my-pi-app-wire-0.6.0.tgz", + "tarballSha256": "92256497bb8086ab9cefa30e4890293060a52b9d0c5349743ee94ae3224ca32c", "appProtocol": "omp-app/1", - "goldenCorpusSha256": "63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e", - "createdAt": "2026-07-19T01:54:54Z" + "goldenCorpusSha256": "7ebd5fa6cbc37ae0f28cf1d957d9cab841b875581cb42ffbe81cea66f1dc2ef1", + "createdAt": "2026-07-19T01:06:38Z" } 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 0000000..254fdfb Binary files /dev/null and b/vendor/app-wire/oh-my-pi-app-wire-0.6.0.tgz differ