diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index 60be746f..7a81cecd 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -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" }), @@ -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-")); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index bddddbbb..1e0b6c5f 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -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"; @@ -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" }), @@ -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" }), @@ -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" }), @@ -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" }), @@ -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(), { @@ -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)); diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index 68a5175a..90793eea 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -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 { @@ -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; hiddenWorkspaces: Set; @@ -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); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index ee49fa04..8ff7fe7d 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -8,6 +8,7 @@ import { type ServerResponse, } from "node:http"; import { URL } from "node:url"; +import { resolve } from "node:path"; import { promisify } from "node:util"; import { subscribeWebCapabilities, @@ -15,7 +16,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, + WebSessionDeletionError, +} from "../adapter/pi-adapter.ts"; import { jsonByteLength, WEB_MAX_EVENT_BYTES, @@ -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) { diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index c9d5cdee..80390d69 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -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, diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 8c60e5c0..f73d0eff 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -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;