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
88 changes: 88 additions & 0 deletions tests/web/pi-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ function runtimeFor(
workspaceSelected: true,
sessionDirectory,
sessionManager,
isSessionOwned: (sessionId, sessionPath) =>
sessionManager.getSessionId() === sessionId ||
(sessionPath !== undefined &&
sessionManager.getSessionFile() === sessionPath),
isIdle: () => true,
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
Expand Down Expand Up @@ -259,6 +263,90 @@ test("first archive mutation preserves previously persisted archive metadata", a
}
});

test("deletes a persisted non-active session and cleans derived metadata", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-web-session-delete-"));
const sessionDirectory = join(root, "sessions");
try {
await mkdir(sessionDirectory, { recursive: true });
const current = SessionManager.inMemory(root);
const candidate = SessionManager.create(root, sessionDirectory);
persistSession(candidate, "delete me", 2);
const candidatePath = candidate.getSessionFile();
assert.ok(candidatePath);
const adapter = new PiWebAdapter(
runtimeFor(root, sessionDirectory, current),
);
await adapter.archiveSession(candidatePath);
await adapter.removeWorkspace(root);

const deletedPath = await adapter.deleteSession(candidatePath);
assert.equal(deletedPath, candidatePath);
await assert.rejects(readFile(candidatePath));
const archived = JSON.parse(
await readFile(join(sessionDirectory, "archived-sessions.json"), "utf8"),
) as string[];
assert.equal(archived.includes(candidatePath), false);
const workspaceState = JSON.parse(
await readFile(join(sessionDirectory, "workspace-state.json"), "utf8"),
) as { ungroupedSessions: string[] };
assert.equal(
workspaceState.ungroupedSessions.includes(candidatePath),
false,
);
assert.equal(
(await adapter.listSessions()).some(
(session) => session.path === candidatePath,
),
false,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("refuses to delete the active session", async () => {
const root = await mkdtemp(
join(tmpdir(), "openpi-web-session-delete-active-"),
);
const sessionDirectory = join(root, "sessions");
try {
await mkdir(sessionDirectory, { recursive: true });
const current = SessionManager.inMemory(root);
const adapter = new PiWebAdapter(
runtimeFor(root, sessionDirectory, current),
);
await assert.rejects(
adapter.deleteSession(`current:${current.getSessionId()}`),
/live Web runtime/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("refuses to delete a Session retained by a live runtime", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-web-session-delete-live-"));
const sessionDirectory = join(root, "sessions");
try {
await mkdir(sessionDirectory, { recursive: true });
const current = SessionManager.inMemory(root);
const candidate = SessionManager.create(root, sessionDirectory);
persistSession(candidate, "retained", 2);
const candidatePath = candidate.getSessionFile();
assert.ok(candidatePath);
const runtime = runtimeFor(root, sessionDirectory, current);
runtime.isSessionOwned = () => true;
const adapter = new PiWebAdapter(runtime);
await assert.rejects(
adapter.deleteSession(candidatePath),
/live Web runtime/u,
);
await readFile(candidatePath);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("corrupt package metadata fails closed without overwriting it", async () => {
const root = await mkdtemp(join(tmpdir(), "openpi-web-corrupt-state-"));
const imported = await mkdtemp(join(tmpdir(), "openpi-web-corrupt-import-"));
Expand Down
81 changes: 78 additions & 3 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -111,6 +111,10 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn
get sessionManager() {
return sessionManager;
},
isSessionOwned: (sessionId, sessionPath) =>
sessionManager.getSessionId() === sessionId ||
(sessionPath !== undefined &&
sessionManager.getSessionFile() === sessionPath),
isIdle: () => false,
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
Expand Down Expand Up @@ -844,6 +848,10 @@ test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses",
workspaceSelected: false,
sessionDirectory: root,
sessionManager,
isSessionOwned: (sessionId, sessionPath) =>
sessionManager.getSessionId() === sessionId ||
(sessionPath !== undefined &&
sessionManager.getSessionFile() === sessionPath),
isIdle: () => true,
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
Expand Down Expand Up @@ -943,6 +951,10 @@ test("returns accepted only after Pi admits the prompt", async () => {
sessionDirectory: cwd,
cwd,
sessionManager,
isSessionOwned: (sessionId, sessionPath) =>
sessionManager.getSessionId() === sessionId ||
(sessionPath !== undefined &&
sessionManager.getSessionFile() === sessionPath),
isIdle: () => false,
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
Expand Down Expand Up @@ -1070,13 +1082,18 @@ function testRuntime(
sendPrompt: WebRuntimeController["sendPrompt"] = async () => ({
pendingFollowUps: 0,
}),
sessionDirectory = cwd,
) {
const sessionManager = SessionManager.inMemory(cwd);
const runtime: WebRuntimeController = {
workspaceSelected: true,
sessionDirectory: cwd,
sessionDirectory,
cwd,
sessionManager,
isSessionOwned: (sessionId, sessionPath) =>
sessionManager.getSessionId() === sessionId ||
(sessionPath !== undefined &&
sessionManager.getSessionFile() === sessionPath),
isIdle: () => true,
getActiveTurn: () => undefined,
cancelTurn: async (options) => ({ ...options, state: "stale-turn" }),
Expand Down Expand Up @@ -1132,7 +1149,7 @@ test("classifies invalid and oversized JSON bodies as client errors", async () =
const oversizedBody = await fetch(`${launched.origin}/api/workspaces`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ path: "x".repeat(16 * 1024) }),
body: JSON.stringify({ path: "x".repeat(32 * 1024) }),
});
assert.equal(oversizedBody.status, 413);
assert.deepEqual(await oversizedBody.json(), {
Expand All @@ -1146,6 +1163,64 @@ test("classifies invalid and oversized JSON bodies as client errors", async () =
}
});

test("requires exact reviewed confirmation before deleting a persisted Session", async () => {
const cwd = await mkdtemp(join(tmpdir(), "openpi-web-session-delete-host-"));
const sessionDirectory = join(cwd, "sessions");
await mkdir(sessionDirectory);
const candidate = SessionManager.create(cwd, sessionDirectory);
candidate.appendMessage({ role: "user", content: "delete me", timestamp: 1 });
candidate.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 sessionPath = candidate.getSessionFile();
assert.ok(sessionPath);
const { host, launched, headers } = await startTestHost(
testRuntime(cwd, undefined, sessionDirectory),
);
try {
const target = `${launched.origin}/api/sessions?path=${encodeURIComponent(sessionPath)}`;
const bare = await fetch(target, { method: "DELETE", headers });
assert.equal(bare.status, 400);
assert.deepEqual(await bare.json(), {
code: "CONFIRMATION_REQUIRED",
error: "Confirm the exact canonical Session path before deletion",
path: sessionPath,
});
await readFile(sessionPath);
const wrong = await fetch(`${target}&confirm=wrong`, {
method: "DELETE",
headers,
});
assert.equal(wrong.status, 400);
const confirmed = await fetch(
`${target}&confirm=${encodeURIComponent(sessionPath)}`,
{ method: "DELETE", headers },
);
assert.equal(confirmed.status, 200);
assert.deepEqual(await confirmed.json(), {
path: sessionPath,
deleted: true,
});
} finally {
await host.stop();
await rm(cwd, { recursive: true, force: true });
}
});

test("classifies an oversized body sent in multiple chunks as a client error", async () => {
const cwd = await mkdtemp(join(tmpdir(), "openpi-web-request-chunks-"));
const { host, launched, headers } = await startTestHost(testRuntime(cwd));
Expand Down
43 changes: 42 additions & 1 deletion web/adapter/pi-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
import {
Expand All @@ -19,6 +19,16 @@ import {
} from "../protocol/types.ts";
import type { WebRuntimeController } from "../runtime/types.ts";

export class WebSessionDeletionError extends Error {
readonly code = "SESSION_CONFLICT" as const;
readonly statusCode = 409 as const;

constructor(message: string) {
super(message);
this.name = "WebSessionDeletionError";
}
}

type WorkspaceStateSnapshot = {
importedWorkspaces: Set<string>;
hiddenWorkspaces: Set<string>;
Expand Down Expand Up @@ -292,6 +302,37 @@ export class PiWebAdapter {
});
}

async deleteSession(path: string) {
await this.ensureWorkspaceStateLoaded();
await this.ensureArchivesLoaded();
const session = await this.requireSession(path);
const canonical = resolve(session.path);
if (this.runtime.isSessionOwned(session.id, canonical)) {
throw new WebSessionDeletionError(
"Cannot delete a Session owned by a live Web runtime",
);
}
const sessionDirectory = resolve(this.runtime.sessionDirectory);
const relativePath = relative(sessionDirectory, canonical);
if (
!relativePath ||
isAbsolute(relativePath) ||
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
!canonical.endsWith(".jsonl")
) {
throw new Error("Session target is outside the Web Session directory");
}
await rm(canonical);
await this.enqueueArchiveMutation((draft) => {
draft.delete(canonical);
});
await this.enqueueWorkspaceMutation((draft) => {
draft.ungroupedSessions.delete(canonical);
});
return canonical;
}

async removeWorkspace(path: string) {
await this.ensureWorkspaceStateLoaded();
const canonical = resolve(path);
Expand Down
34 changes: 33 additions & 1 deletion web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ import {
type ServerResponse,
} from "node:http";
import { URL } from "node:url";
import { resolve } from "node:path";
import { promisify } from "node:util";
import {
subscribeWebCapabilities,
webCapabilityDetail,
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,
WebSessionDeletionError,
} from "../adapter/pi-adapter.ts";
import {
jsonByteLength,
WEB_MAX_EVENT_BYTES,
Expand Down Expand Up @@ -520,6 +524,34 @@ export class WebHost {
this.publish("session_unarchived", { sessionPath: path });
return this.json(response, 200, { path, archived: false });
}
if (url.pathname === "/api/sessions" && request.method === "DELETE") {
const path = url.searchParams.get("path");
const confirmation = url.searchParams.get("confirm");
if (!path)
return this.json(response, 400, { error: "session path is required" });
try {
const session = await this.adapter.requireSession(path);
const canonical = resolve(session.path);
if (confirmation !== canonical) {
return this.json(response, 400, {
code: "CONFIRMATION_REQUIRED",
error: "Confirm the exact canonical Session path before deletion",
path: canonical,
});
}
const deletedPath = await this.adapter.deleteSession(canonical);
this.publish("session_deleted", { sessionPath: deletedPath });
return this.json(response, 200, { path: deletedPath, deleted: true });
} catch (error) {
if (error instanceof WebSessionDeletionError) {
return this.json(response, error.statusCode, {
code: error.code,
error: error.message,
});
}
throw error;
}
}
if (url.pathname === "/api/sessions/select" && request.method === "POST") {
const body = await this.readJson(request);
if (typeof body.path !== "string" || body.path.trim().length === 0) {
Expand Down
18 changes: 18 additions & 0 deletions web/runtime/pi-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,24 @@ export class PiWebRuntime implements WebRuntimeController {
);
}

isSessionOwned(sessionId: string, sessionPath?: string) {
const runtimes = new Set([
this.runtime,
...this.retainedRuntimes,
...this.candidateRuntimes,
]);
return [...runtimes].some((runtime) => {
const manager = runtime.session.sessionManager;
if (manager.getSessionId() === sessionId) return true;
const ownedPath = manager.getSessionFile();
return (
sessionPath !== undefined &&
ownedPath !== undefined &&
resolve(ownedPath) === resolve(sessionPath)
);
});
}

private async createNewSession(
workspacePath: string,
options?: WebSessionCreationOptions,
Expand Down
1 change: 1 addition & 0 deletions web/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export interface WebRuntimeController {
readonly workspaceSelected: boolean;
readonly sessionDirectory: string;
readonly sessionManager: SessionManager;
isSessionOwned(sessionId: string, sessionPath?: string): boolean;
getProjectTrustStatus?(): WebProjectTrustStatus;
isIdle(): boolean;
getActiveTurn(): WebActiveTurn | undefined;
Expand Down
Loading