Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,52 @@ test("snapshot pins current and selected sessions while bounding the projection"
}
});

test("discovers default Pi sessions as bounded read-only projections", async () => {
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 adapter = new PiWebAdapter(
runtimeFor(root, sessionDirectory, current),
);

const listed = await adapter.listReadOnlyTerminalSessions({ limit: 1 });
assert.equal(listed.total, 1);
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((await SessionManager.listAll(sessionDirectory)).length, 0);
} 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");
Expand Down
137 changes: 137 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,143 @@ 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(
(
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);
} 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");
Expand Down
94 changes: 94 additions & 0 deletions web/adapter/pi-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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 { loadSessionPreviewData } from "../../extensions/sessions/preview-loader.ts";
import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
import {
boundedText,
Expand All @@ -19,6 +20,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<string>;
hiddenWorkspaces: Set<string>;
Expand Down Expand Up @@ -427,6 +438,89 @@ export class PiWebAdapter {
return (await this.listSessionProjection(pinnedPath)).sessions;
}

async listReadOnlyTerminalSessions(
options: { query?: string; cursor?: number; limit?: number } = {},
) {
const workspace = await this.requireWorkspace(this.runtime.cwd);
const query = options.query?.trim().toLocaleLowerCase() ?? "";
const cursor = options.cursor ?? 0;
const limit = options.limit ?? 50;
const sessions = (await SessionManager.listAll())
.filter((session) => resolve(session.cwd) === workspace)
.filter((session) => {
if (!query) return true;
return [session.name, session.cwd, session.firstMessage].some((value) =>
value?.toLocaleLowerCase().includes(query),
);
});
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) {
const workspace = await this.requireWorkspace(this.runtime.cwd);
const canonical = resolve(path);
const session = (await SessionManager.listAll()).find(
(candidate) =>
resolve(candidate.path) === canonical &&
resolve(candidate.cwd) === workspace,
);
if (!session) {
throw new WebReadOnlySessionError("Terminal Session is not available");
}
const preview = await loadSessionPreviewData(session.path);
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);
Expand Down
56 changes: 55 additions & 1 deletion web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,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,
Expand Down Expand Up @@ -684,6 +687,57 @@ 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",
});
}
try {
const path = url.searchParams.get("path");
if (path) {
return this.json(
response,
200,
await this.adapter.getReadOnlyTerminalSession(path),
);
}
return this.json(
response,
200,
await this.adapter.listReadOnlyTerminalSessions({
query,
cursor: cursor.value,
limit,
}),
);
} catch (error) {
if (error instanceof WebReadOnlySessionError) {
return this.json(response, error.statusCode, {
code: error.code,
error: error.message,
});
}
throw error;
}
}
if (url.pathname === "/api/models")
return this.json(response, 200, { models: this.runtime.listModels() });
if (url.pathname === "/api/capabilities")
Expand Down
Loading