From 921bd5bdd6a46b2ff67af19040ca4ec00307ab68 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:49:22 +0800 Subject: [PATCH] feat(web): add safe persisted session deletion --- tests/web/pi-adapter.test.ts | 61 ++++++++++++++++++++++++++++++++++++ tests/web/web-host.test.ts | 61 ++++++++++++++++++++++++++++++++++++ web/adapter/pi-adapter.ts | 45 +++++++++++++++++++++++++- web/host/web-host.ts | 23 +++++++++++++- 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index af2f7476..df38be8c 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -233,6 +233,67 @@ 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()}`), + /active Session/u, + ); + } 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 d80df4fa..d00e2ede 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -91,6 +91,35 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn Authorization: `Bearer ${token}`, "Content-Type": "application/json", }; + const deletable = SessionManager.create(cwd, cwd); + deletable.appendMessage({ + role: "user", + content: "deletable session", + timestamp: 1, + }); + deletable.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 page = await fetch(`${launched.origin}/`); assert.equal(page.status, 200); @@ -308,6 +337,10 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn }); const currentSessionPath = listedSessions.sessions[0]?.path; assert.ok(currentSessionPath); + const deletableSessionPath = listedSessions.sessions.find( + (session) => session.path !== currentSessionPath, + )?.path; + assert.equal(deletableSessionPath, deletable.getSessionFile()); const sessionRename = await fetch(`${launched.origin}/api/sessions`, { method: "PATCH", headers: authorized, @@ -332,6 +365,34 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn true, ); + const deleteActive = await fetch( + `${launched.origin}/api/sessions?path=${encodeURIComponent(currentSessionPath)}`, + { method: "DELETE", headers: authorized }, + ); + assert.equal(deleteActive.status, 409); + assert.deepEqual(await deleteActive.json(), { + code: "SESSION_CONFLICT", + error: "Cannot delete the active Session", + }); + const deleteSession = await fetch( + `${launched.origin}/api/sessions?path=${encodeURIComponent(deletableSessionPath!)}`, + { method: "DELETE", headers: authorized }, + ); + assert.equal(deleteSession.status, 200); + assert.deepEqual(await deleteSession.json(), { + path: deletableSessionPath, + deleted: true, + }); + const afterDelete = (await ( + await fetch(`${launched.origin}/api/sessions`, { headers: authorized }) + ).json()) as { sessions: Array<{ path: string }> }; + assert.equal( + afterDelete.sessions.some( + (session) => session.path === deletableSessionPath, + ), + false, + ); + const wrongSession = await fetch(`${launched.origin}/api/prompt`, { method: "POST", headers: authorized, diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index fb4c0bc9..f05794b9 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; @@ -284,6 +294,39 @@ export class PiWebAdapter { }); } + async deleteSession(path: string) { + await this.ensureWorkspaceStateLoaded(); + await this.ensureArchivesLoaded(); + const session = await this.requireSession(path); + const canonical = resolve(session.path); + const activePath = this.runtime.sessionManager.getSessionFile(); + if ( + session.id === this.runtime.sessionManager.getSessionId() || + (activePath !== undefined && resolve(activePath) === canonical) + ) { + throw new WebSessionDeletionError("Cannot delete the active Session"); + } + 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 6203e647..339881e6 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -10,7 +10,10 @@ import { import { URL } from "node:url"; import { promisify } from "node:util"; import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts"; -import { PiWebAdapter } from "../adapter/pi-adapter.ts"; +import { + PiWebAdapter, + WebSessionDeletionError, +} from "../adapter/pi-adapter.ts"; import { jsonByteLength, WEB_MAX_EVENT_BYTES, @@ -425,6 +428,24 @@ export class WebHost { this.publish("session_archived", { sessionPath: path }); return this.json(response, 200, { path, archived: true }); } + if (url.pathname === "/api/sessions" && request.method === "DELETE") { + const path = url.searchParams.get("path"); + if (!path) + return this.json(response, 400, { error: "session path is required" }); + try { + const deletedPath = await this.adapter.deleteSession(path); + 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) {