diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index 60be746f..5bec3304 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import fsPromises from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { mkdir, mkdtemp, @@ -147,6 +149,207 @@ test("snapshot pins current and selected sessions while bounding the projection" } }); +test("discovers default Pi sessions as bounded read-only projections", async (t) => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-terminal-history-")); + const sessionDirectory = join(root, "web-sessions"); + const agentDirectory = join(root, "pi-agent"); + const previousAgentDirectory = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDirectory; + try { + await mkdir(sessionDirectory, { recursive: true }); + const current = SessionManager.inMemory(root); + const terminal = SessionManager.create(root); + persistSession(terminal, "terminal history", 2); + const terminalPath = terminal.getSessionFile(); + assert.ok(terminalPath); + const unrelatedWorkspace = join(root, "unrelated-workspace"); + await mkdir(unrelatedWorkspace); + const unrelated = SessionManager.create(unrelatedWorkspace); + persistSession(unrelated, "unrelated first message", 3); + unrelated.appendMessage({ + role: "user", + content: "unrelated-only-token", + timestamp: 4, + }); + const unrelatedPath = unrelated.getSessionFile(); + assert.ok(unrelatedPath); + const fileBefore = await readFile(terminalPath); + const originalOpen = fsPromises.open; + const openedPaths: string[] = []; + t.mock.method( + fsPromises, + "open", + (...args: Parameters) => { + openedPaths.push(String(args[0])); + assert.notEqual(String(args[0]), unrelatedPath); + return originalOpen(...args); + }, + ); + syncBuiltinESMExports(); + t.after(() => { + t.mock.restoreAll(); + syncBuiltinESMExports(); + }); + const adapter = new PiWebAdapter( + runtimeFor(root, sessionDirectory, current), + ); + const listAll = SessionManager.listAll; + SessionManager.listAll = async () => { + throw new Error("unrelated Session discovery must not be used"); + }; + try { + const listed = await adapter.listReadOnlyTerminalSessions({ limit: 1 }); + assert.equal(listed.total, 1); + assert.equal("allMessagesText" in listed.sessions[0]!, false); + assert.deepEqual(listed.sessions[0], { + id: terminal.getSessionId(), + path: terminalPath, + cwd: root, + modified: listed.sessions[0]?.modified, + created: listed.sessions[0]?.created, + messageCount: 2, + firstMessage: "terminal history", + source: "pi-default", + origin: "terminal", + readOnly: true, + }); + const inspected = await adapter.getReadOnlyTerminalSession(terminalPath); + assert.equal(inspected.readOnly, true); + assert.equal(inspected.source, "pi-default"); + assert.equal(inspected.preview.messages.length, 2); + assert.equal("allMessagesText" in inspected, false); + assert.ok(openedPaths.includes(terminalPath)); + assert.equal( + ( + await adapter.listReadOnlyTerminalSessions({ + query: "unrelated-only-token", + }) + ).total, + 0, + ); + await assert.rejects( + adapter.getReadOnlyTerminalSession(unrelatedPath), + (error: unknown) => + error instanceof Error && + (error as { code?: string }).code === "SESSION_NOT_FOUND", + ); + const cancelled = AbortSignal.abort(); + const opensBefore = openedPaths.length; + await assert.rejects( + adapter.getReadOnlyTerminalSession(terminalPath, { signal: cancelled }), + { name: "AbortError" }, + ); + await assert.rejects( + adapter.listReadOnlyTerminalSessions({ signal: cancelled }), + { name: "AbortError" }, + ); + assert.equal(openedPaths.length, opensBefore); + + const controller = new AbortController(); + let targetOpens = 0; + t.mock.method( + fsPromises, + "open", + async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (String(args[0]) === terminalPath && ++targetOpens === 2) { + const read = handle.read.bind(handle); + t.mock.method( + handle, + "read", + async (...readArgs: Parameters) => { + const result = await read(...readArgs); + controller.abort(); + return result; + }, + ); + } + return handle; + }, + ); + syncBuiltinESMExports(); + await assert.rejects( + adapter.getReadOnlyTerminalSession(terminalPath, { + signal: controller.signal, + }), + { name: "AbortError" }, + ); + assert.equal( + targetOpens, + 2, + "cancellation occurs during the preview, after metadata admission", + ); + } finally { + SessionManager.listAll = listAll; + } + assert.equal((await SessionManager.listAll(sessionDirectory)).length, 0); + assert.deepEqual(await readFile(terminalPath), fileBefore); + assert.equal( + (await adapter.listSessions()).some( + (session) => session.path === terminalPath, + ), + false, + ); + assert.equal( + (await adapter.getSnapshot()).currentSessionId, + current.getSessionId(), + ); + const hidden = new PiWebAdapter( + runtimeFor(root, sessionDirectory, current), + ); + await hidden.removeWorkspace(root); + const unboundRuntime = { + ...runtimeFor(root, sessionDirectory, current), + workspaceSelected: false, + }; + const unbound = new PiWebAdapter(unboundRuntime); + const originalReaddir = fsPromises.readdir; + t.mock.method( + fsPromises, + "readdir", + (...args: Parameters) => { + assert.ok( + !String(args[0]).startsWith(agentDirectory), + "unavailable workspace must not walk the default store", + ); + return originalReaddir(...args); + }, + ); + syncBuiltinESMExports(); + await assert.rejects(hidden.listReadOnlyTerminalSessions()); + await assert.rejects(unbound.listReadOnlyTerminalSessions()); + + const firstKept = terminal.appendMessage({ + role: "user", + content: "kept after compaction", + timestamp: 5, + }); + terminal.appendCompaction("summary before kept window", firstKept, 100); + const compacted = await adapter.getReadOnlyTerminalSession(terminalPath); + assert.ok( + JSON.stringify(compacted.preview.messages).includes( + "kept after compaction", + ), + ); + assert.ok( + !JSON.stringify(compacted.preview.messages).includes("terminal history"), + ); + assert.ok(compacted.preview.messages.length <= 80); + assert.ok(compacted.preview.retainedBytes <= 1024 * 1024); + await assert.rejects( + readFile(join(sessionDirectory, "archived-sessions.json")), + { code: "ENOENT" }, + ); + } finally { + if (previousAgentDirectory === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDirectory; + } + await rm(root, { recursive: true, force: true }); + } +}); + test("an unbound Web runtime never projects its bootstrap cwd as a workspace or Session", async () => { const root = await mkdtemp(join(tmpdir(), "openpi-web-unbound-")); const bootstrap = join(root, ".bootstrap-workspace"); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index bddddbbb..3a8dc3e1 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -832,6 +832,163 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn } }); +test("serves terminal Sessions through a read-only bounded endpoint", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-terminal-host-")); + const previousAgentDirectory = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = join(root, "pi-agent"); + const sessionManager = SessionManager.inMemory(root); + const terminal = SessionManager.create(root); + terminal.appendMessage({ + role: "user", + content: "terminal endpoint", + timestamp: 1, + }); + terminal.appendMessage({ + role: "assistant", + content: [], + api: "openai-responses", + provider: "fixture", + model: "fixture", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 1, + }); + const runtime: WebRuntimeController = { + cwd: root, + workspaceSelected: true, + sessionDirectory: join(root, "web-sessions"), + sessionManager, + isIdle: () => true, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), + sendPrompt: async () => ({ pendingFollowUps: 0 }), + newSession: async () => ({ cancelled: false }), + switchSession: async () => ({ cancelled: false }), + listModels: () => [], + setModel: async () => { + throw new Error("not available"); + }, + subscribe: () => () => {}, + dispose: async () => {}, + }; + const host = new WebHost({ runtime }); + try { + await host.start(); + const launched = new URL(host.url); + const token = new URLSearchParams(launched.hash.slice(1)).get("token"); + assert.ok(token); + const headers = { Authorization: `Bearer ${token}` }; + const listed = await fetch( + `${launched.origin}/api/terminal-sessions?limit=1`, + { + headers, + }, + ); + assert.equal(listed.status, 200); + const page = (await listed.json()) as { + sessions: Array<{ + path: string; + source: string; + origin: string; + readOnly: boolean; + }>; + total: number; + }; + assert.equal(page.total, 1); + assert.equal(page.sessions[0]?.path, terminal.getSessionFile()); + assert.equal(page.sessions[0]?.source, "pi-default"); + assert.equal(page.sessions[0]?.origin, "terminal"); + assert.equal(page.sessions[0]?.readOnly, true); + assert.equal(JSON.stringify(page).includes("allMessagesText"), false); + assert.equal( + ( + await fetch( + `${launched.origin}/api/terminal-sessions?query=${"x".repeat(201)}`, + { headers }, + ) + ).status, + 400, + ); + assert.equal( + ( + await fetch(`${launched.origin}/api/terminal-sessions?cursor=nope`, { + headers, + }) + ).status, + 400, + ); + assert.equal( + ( + await fetch(`${launched.origin}/api/terminal-sessions?limit=101`, { + headers, + }) + ).status, + 400, + ); + const missing = await fetch( + `${launched.origin}/api/terminal-sessions?path=${encodeURIComponent(join(root, "missing.jsonl"))}`, + { headers }, + ); + assert.equal(missing.status, 404); + assert.deepEqual(await missing.json(), { + code: "SESSION_NOT_FOUND", + error: "Terminal Session is not available", + }); + const inspected = await fetch( + `${launched.origin}/api/terminal-sessions?path=${encodeURIComponent(terminal.getSessionFile()!)}`, + { headers }, + ); + assert.equal(inspected.status, 200); + const details = (await inspected.json()) as { + readOnly: boolean; + preview: { messages: unknown[]; retainedBytes: number }; + }; + assert.equal(details.readOnly, true); + assert.equal(details.preview.messages.length, 2); + assert.ok(details.preview.retainedBytes > 0); + assert.equal(JSON.stringify(details).includes("allMessagesText"), false); + for (const method of ["POST", "PATCH", "DELETE"]) { + const rejected = await fetch(`${launched.origin}/api/terminal-sessions`, { + method, + headers, + }); + assert.equal(rejected.status, 405); + } + const capabilities = await fetch(`${launched.origin}/api/capabilities`, { + headers, + }); + assert.equal( + (await capabilities.json()).sessionId, + sessionManager.getSessionId(), + ); + const webSessions = await fetch(`${launched.origin}/api/sessions`, { + headers, + }); + assert.ok(!JSON.stringify(await webSessions.json()).includes("pi-default")); + } finally { + await host.stop(); + if (previousAgentDirectory === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDirectory; + } + await rm(root, { recursive: true, force: true }); + } +}); + test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", async () => { const root = await mkdtemp(join(tmpdir(), "openpi-web-unbound-host-")); const bootstrap = join(root, ".bootstrap-workspace"); diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index 68a5175a..5c8d695e 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -1,6 +1,20 @@ -import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; -import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { + lstat, + open, + readFile, + readdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import { + getAgentDir, + SessionManager, +} from "@earendil-works/pi-coding-agent"; +import { loadSessionPreviewData } from "../../extensions/sessions/preview-loader.ts"; import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts"; import { boundedText, @@ -19,6 +33,16 @@ import { } from "../protocol/types.ts"; import type { WebRuntimeController } from "../runtime/types.ts"; +export class WebReadOnlySessionError extends Error { + readonly code = "SESSION_NOT_FOUND" as const; + readonly statusCode = 404 as const; + + constructor(message: string) { + super(message); + this.name = "WebReadOnlySessionError"; + } +} + type WorkspaceStateSnapshot = { importedWorkspaces: Set; hiddenWorkspaces: Set; @@ -27,6 +51,181 @@ type WorkspaceStateSnapshot = { restoreInitialWorkspace: boolean; }; +const TERMINAL_DISCOVERY_MAX_BYTES = 256 * 1024; +const TERMINAL_DISCOVERY_MAX_FILES = WEB_MAX_SESSIONS; + +type ReadOnlyTerminalSessionInfo = { + id: string; + path: string; + cwd: string; + name?: string; + modified: Date; + created: Date; + messageCount: number; + firstMessage: string; +}; + +function defaultTerminalSessionDirectory(cwd: string) { + const resolvedCwd = resolve(cwd); + const encoded = `--${resolvedCwd.replace(/^[/\\]/u, "").replace(/[/\\:]/gu, "-")}--`; + return join(getAgentDir(), "sessions", encoded); +} + +function containedPath(parent: string, candidate: string) { + const child = relative(parent, candidate); + return ( + child.length > 0 && + child !== ".." && + !child.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && + !isAbsolute(child) + ); +} + +function terminalTextContent(message: Record) { + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (part): part is { type: "text"; text: string } => + !!part && + typeof part === "object" && + (part as Record).type === "text" && + typeof (part as Record).text === "string", + ) + .map((part) => part.text) + .join(" "); +} + +async function readTerminalSessionInfo( + filePath: string, + modified: Date, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + let fileStat; + try { + fileStat = await lstat(filePath); + if (!fileStat.isFile() || fileStat.isSymbolicLink()) return undefined; + } catch { + return undefined; + } + + let handle; + try { + handle = await open(filePath, "r"); + } catch { + return undefined; + } + try { + signal?.throwIfAborted(); + const length = Math.min(fileStat.size, TERMINAL_DISCOVERY_MAX_BYTES); + const bytes = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(bytes, 0, length, 0); + signal?.throwIfAborted(); + const text = bytes.toString("utf8", 0, bytesRead); + const lines = text.split(/\r?\n/u); + if (bytesRead < fileStat.size) lines.pop(); + + let header: Record | undefined; + let name: string | undefined; + let firstMessage = ""; + let messageCount = 0; + for (const line of lines) { + if (!line.trim()) continue; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const entry = value as Record; + if (!header) { + if (entry.type !== "session" || typeof entry.id !== "string") { + return undefined; + } + header = entry; + continue; + } + if (entry.type === "session_info") { + const entryName = entry.name; + name = typeof entryName === "string" ? entryName.trim() || undefined : name; + continue; + } + if (entry.type !== "message") continue; + messageCount++; + const message = entry.message; + if ( + !firstMessage && + message && + typeof message === "object" && + !Array.isArray(message) && + (message as Record).role === "user" + ) { + firstMessage = terminalTextContent(message as Record); + } + } + if (!header || typeof header.cwd !== "string") return undefined; + const created = + typeof header.timestamp === "string" && !Number.isNaN(Date.parse(header.timestamp)) + ? new Date(header.timestamp) + : modified; + return { + id: header.id as string, + path: filePath, + cwd: resolve(header.cwd), + ...(name ? { name } : {}), + modified, + created, + messageCount, + firstMessage: firstMessage || "(no messages)", + }; + } finally { + await handle.close(); + } +} + +async function listTerminalSessionInfo(workspace: string, signal?: AbortSignal) { + signal?.throwIfAborted(); + const directory = defaultTerminalSessionDirectory(workspace); + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + const candidates = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map(async (entry) => { + signal?.throwIfAborted(); + const path = join(directory, entry.name); + try { + const fileStat = await stat(path); + return { path, modified: fileStat.mtime }; + } catch { + return undefined; + } + }), + ); + const infos = await Promise.all( + candidates + .filter((candidate): candidate is { path: string; modified: Date } => !!candidate) + .sort((left, right) => right.modified.getTime() - left.modified.getTime()) + .slice(0, TERMINAL_DISCOVERY_MAX_FILES) + .map((candidate) => readTerminalSessionInfo(candidate.path, candidate.modified, signal)), + ); + return infos + .filter( + (session): session is ReadOnlyTerminalSessionInfo => + !!session && session.cwd === workspace, + ) + .sort((left, right) => right.modified.getTime() - left.modified.getTime()); +} + export class PiWebAdapter { private readonly runtime: WebRuntimeController; private readonly importedWorkspaces = new Set(); @@ -223,6 +422,18 @@ export class PiWebAdapter { return canonical; } + private async requireSelectedWorkspace() { + await this.ensureWorkspaceStateLoaded(); + const workspace = resolve(this.runtime.cwd); + if ( + this.runtime.workspaceSelected !== true || + this.hiddenWorkspaces.has(workspace) + ) { + throw new Error("Workspace is not available"); + } + return workspace; + } + async requireSession(path: string) { await this.ensureWorkspaceStateLoaded(); const sessions = await this.listSessions(path); @@ -444,6 +655,104 @@ export class PiWebAdapter { return (await this.listSessionProjection(pinnedPath)).sessions; } + async listReadOnlyTerminalSessions( + options: { query?: string; cursor?: number; limit?: number; signal?: AbortSignal } = {}, + ) { + options.signal?.throwIfAborted(); + const workspace = await this.requireSelectedWorkspace(); + const query = options.query?.trim().toLocaleLowerCase() ?? ""; + const cursor = options.cursor ?? 0; + const limit = options.limit ?? 50; + const sessions = (await listTerminalSessionInfo(workspace, options.signal)) + .filter((session) => { + if (!query) return true; + return [session.name, session.cwd, session.firstMessage].some((value) => + value?.toLocaleLowerCase().includes(query), + ); + }); + options.signal?.throwIfAborted(); + const page = sessions.slice(cursor, cursor + limit); + return { + sessions: page.map((session) => ({ + id: session.id, + path: session.path, + cwd: resolve(session.cwd), + ...(session.name + ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } + : {}), + modified: session.modified.toISOString(), + created: session.created.toISOString(), + messageCount: session.messageCount, + firstMessage: boundedText( + session.firstMessage, + WEB_MAX_SESSION_PREVIEW, + ), + source: "pi-default" as const, + origin: "terminal" as const, + readOnly: true as const, + })), + cursor, + nextCursor: + cursor + page.length < sessions.length + ? cursor + page.length + : undefined, + total: sessions.length, + }; + } + + async getReadOnlyTerminalSession(path: string, options: { signal?: AbortSignal } = {}) { + options.signal?.throwIfAborted(); + const workspace = await this.requireSelectedWorkspace(); + const canonical = resolve(path); + const directory = defaultTerminalSessionDirectory(workspace); + let session: ReadOnlyTerminalSessionInfo | undefined; + if (containedPath(directory, canonical)) { + try { + session = await readTerminalSessionInfo( + canonical, + (await stat(canonical)).mtime, + options.signal, + ); + } catch { + options.signal?.throwIfAborted(); + session = undefined; + } + } + if (!session) { + throw new WebReadOnlySessionError("Terminal Session is not available"); + } + if (session.cwd !== workspace) { + throw new WebReadOnlySessionError("Terminal Session is not available"); + } + const preview = await loadSessionPreviewData(session.path, options); + options.signal?.throwIfAborted(); + return { + id: session.id, + path: session.path, + cwd: resolve(session.cwd), + ...(session.name + ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } + : {}), + modified: session.modified.toISOString(), + created: session.created.toISOString(), + messageCount: session.messageCount, + firstMessage: boundedText( + session.firstMessage, + WEB_MAX_SESSION_PREVIEW, + ), + source: "pi-default" as const, + origin: "terminal" as const, + readOnly: true as const, + preview: { + messages: preview.messages, + totalMessages: preview.totalMessages, + bytesRead: preview.bytesRead, + retainedBytes: preview.retainedBytes, + truncatedBytes: preview.truncatedBytes, + }, + }; + } + async getSnapshot(selectedPath?: string) { await this.ensureWorkspaceStateLoaded(); const sessionProjection = await this.listSessionProjection(selectedPath); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index ee49fa04..72bfb5c4 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -15,7 +15,10 @@ import { webCapabilitySnapshot, } from "../../extensions/shared/web-observer-registry.ts"; import { loadSetupConfig } from "../../extensions/shared/setup-config.ts"; -import { PiWebAdapter } from "../adapter/pi-adapter.ts"; +import { + PiWebAdapter, + WebReadOnlySessionError, +} from "../adapter/pi-adapter.ts"; import { jsonByteLength, WEB_MAX_EVENT_BYTES, @@ -723,6 +726,66 @@ export class WebHost { }, }); } + if (url.pathname === "/api/terminal-sessions") { + const query = url.searchParams.get("query") ?? ""; + if (query.length > 200) { + return this.json(response, 400, { + code: "QUERY_TOO_LONG", + error: "query must be at most 200 characters", + }); + } + const cursor = this.parseCursor(url.searchParams.get("cursor")); + if (cursor.invalid) { + return this.json(response, 400, { + code: "INVALID_CURSOR", + error: "cursor must be a non-negative integer", + }); + } + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? 50 : Number(rawLimit); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + return this.json(response, 400, { + code: "INVALID_LIMIT", + error: "limit must be an integer from 1 to 100", + }); + } + const controller = new AbortController(); + const abort = () => controller.abort(); + request.once("aborted", abort); + response.once("close", abort); + const signal = AbortSignal.any([controller.signal, this.chooserAbort.signal]); + try { + const path = url.searchParams.get("path"); + if (path) { + return this.json( + response, + 200, + await this.adapter.getReadOnlyTerminalSession(path, { signal }), + ); + } + return this.json( + response, + 200, + await this.adapter.listReadOnlyTerminalSessions({ + query, + cursor: cursor.value, + limit, + signal, + }), + ); + } catch (error) { + if (error instanceof WebReadOnlySessionError) { + return this.json(response, error.statusCode, { + code: error.code, + error: error.message, + }); + } + throw error; + } finally { + request.off("aborted", abort); + response.off("close", abort); + } + } if (url.pathname === "/api/models") return this.json(response, 200, { models: this.runtime.listModels() }); if (url.pathname === "/api/trust") {