From 4cd6f14f6db4ff1012f1c405e33b33ddbdb77510 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 1 Aug 2026 16:19:35 +0100 Subject: [PATCH] fix: allow write-scoped archived session deletion (#117468) --- CHANGELOG.md | 1 + .../session-management.archive.e2e.test.ts | 14 ++-- ui/src/pages/sessions/sessions-page.test.ts | 69 ++++++++++++++++++- ui/src/pages/sessions/sessions-page.ts | 32 +++++---- 4 files changed, 97 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d5d00278e..7d50996ecde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI archived session deletion:** send archive-gated delete requests from Sessions-page row and mixed-selection actions so write-scoped operators can remove archived threads while active-session deletion remains admin-only. Thanks @shakkernerd. - **Control UI command recovery:** keep delayed detached and immediate command failures scoped to their submitting session, preserving failed drafts and attachments for that pane without overwriting the active session. Fixes #116846. Thanks @shakkernerd. - **Microsoft Teams message-tool replies:** keep automatic live previews from duplicating a message already delivered to the current Teams conversation, while preserving distinct follow-up text and cross-conversation sends. Fixes #116397. (#116398) Thanks @a-tokyo. - **Buzz plugin packaging:** keep the live QA runner on the shipped QA runner SDK surface and remove the obsolete package shrinkwrap so standalone npm and ClawHub package builds use current host exports and dependency resolutions. Thanks @shakkernerd. diff --git a/ui/src/e2e/session-management.archive.e2e.test.ts b/ui/src/e2e/session-management.archive.e2e.test.ts index ca6d258a1bc..ebdcc224986 100644 --- a/ui/src/e2e/session-management.archive.e2e.test.ts +++ b/ui/src/e2e/session-management.archive.e2e.test.ts @@ -502,7 +502,7 @@ suite.define(() => { } }); - it("keeps a session row when the Gateway reports no deletion", async () => { + it("archive-gates a row-menu delete and keeps the row when the Gateway reports no deletion", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -515,7 +515,9 @@ suite.define(() => { "sessions.delete": { ok: true, deleted: false }, "sessions.list": sessionsListResponse([ sessionRow("agent:main:main", "Main", Date.parse("2026-07-01T16:00:00.000Z")), - sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z")), + sessionRow(key, "Research notes", Date.parse("2026-07-01T15:00:00.000Z"), { + archived: true, + }), ]), }, sessionKey: "agent:main:main", @@ -523,7 +525,7 @@ suite.define(() => { page.on("dialog", (dialog) => void dialog.accept()); try { - await page.goto(`${suite.server.baseUrl}sessions`); + await page.goto(`${suite.server.baseUrl}sessions?status=archived`); const row = page.locator(".session-data-row").filter({ hasText: "Research notes" }); await row.waitFor({ state: "visible", timeout: 10_000 }); @@ -533,7 +535,11 @@ suite.define(() => { ); const request = await gateway.waitForRequest("sessions.delete"); - expect(requireRecord(request.params)).toMatchObject({ key }); + expect(requireRecord(request.params)).toMatchObject({ + archivedOnly: true, + deleteTranscript: true, + key, + }); await row.waitFor({ state: "visible" }); } finally { await context.close(); diff --git a/ui/src/pages/sessions/sessions-page.test.ts b/ui/src/pages/sessions/sessions-page.test.ts index 5b89e57f317..6dc82d208c5 100644 --- a/ui/src/pages/sessions/sessions-page.test.ts +++ b/ui/src/pages/sessions/sessions-page.test.ts @@ -548,24 +548,87 @@ describe("sessions page lifecycle", () => { expect(page.selectedKeys).toEqual(new Set()); }); - it("routes a confirmed row-menu deletion through the scoped bulk owner", async () => { + it("archive-gates a confirmed archived row-menu deletion", async () => { const key = "agent:main:work"; const sessions = createSessions({ deleteMany: vi.fn(async () => ({ deleted: [key], errors: [], preservedWorktrees: [] })), }); const { gateway } = createGateway({} as GatewayBrowserClient); const page = await createPage(createContext(gateway, sessions)); - const row = { key, label: "Work" } as GatewaySessionRow; + const row = { key, label: "Work", archived: true } as GatewaySessionRow; page.result = { count: 1, sessions: [row] } as SessionsListResult; const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); await page.deleteSessionFromMenu(row); expect(confirm).toHaveBeenCalledOnce(); - expect(sessions.deleteMany).toHaveBeenCalledWith([{ key, agentId: undefined }]); + expect(sessions.deleteMany).toHaveBeenCalledWith([ + { key, agentId: undefined, archivedOnly: true }, + ]); expect(page.result?.sessions).toEqual([]); }); + it.each([ + ["active", false], + ["unknown", undefined], + ] as const)("keeps %s row-menu deletion admin-only", async (_state, archived) => { + const key = `agent:main:${_state}`; + const sessions = createSessions({ + deleteMany: vi.fn(async () => ({ deleted: [], errors: [], preservedWorktrees: [] })), + }); + const { gateway } = createGateway({} as GatewayBrowserClient); + const page = await createPage(createContext(gateway, sessions)); + const row = { + key, + label: _state, + ...(archived === undefined ? {} : { archived }), + } as GatewaySessionRow; + page.result = { count: 1, sessions: [row] } as SessionsListResult; + vi.spyOn(window, "confirm").mockReturnValue(true); + + await page.deleteSessionFromMenu(row); + + expect(sessions.deleteMany).toHaveBeenCalledWith([{ key, agentId: undefined }]); + }); + + it("derives archive gates per selected row and keeps unknown rows admin-only", async () => { + const activeKey = "agent:main:active"; + const archivedKey = "agent:main:archived"; + const unknownKey = "agent:main:unknown"; + const sessions = createSessions({ + deleteMany: vi.fn(async () => ({ + deleted: [archivedKey], + errors: ["active denied", "unknown denied"], + preservedWorktrees: [], + })), + }); + const { gateway } = createGateway({} as GatewayBrowserClient); + const page = await createPage(createContext(gateway, sessions)); + page.result = { + count: 2, + sessions: [ + { key: activeKey, archived: false }, + { key: archivedKey, archived: true }, + ], + } as SessionsListResult; + page.selectedKeys = new Set([activeKey, archivedKey, unknownKey]); + vi.spyOn(window, "confirm").mockReturnValue(true); + + await page.deleteSelected(); + + expect(sessions.deleteMany).toHaveBeenCalledWith([ + { key: activeKey, agentId: undefined }, + { key: archivedKey, agentId: undefined, archivedOnly: true }, + { key: unknownKey, agentId: undefined }, + ]); + expect(page.result).toMatchObject({ + count: 1, + sessions: [{ key: activeKey, archived: false }], + }); + expect(page.selectedKeys).toEqual(new Set([activeKey, unknownKey])); + expect(page.error).toBe("active denied; unknown denied"); + }); + it("stops an active cloud worker and refreshes the session roster", async () => { const request = vi.fn(() => Promise.resolve({ ok: true })); const list = vi.fn(async () => ({ count: 0, sessions: [] }) as unknown as SessionsListResult); diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index 7dbaa4225ca..e2cbe10235b 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -88,6 +88,8 @@ type SessionsPageRequestScope = { type SessionsPageMutationResult = "completed" | "failed" | "stale"; +type SessionDeleteRow = Pick; + class SessionsPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; @@ -693,14 +695,17 @@ class SessionsPage extends OpenClawLightDomElement { ) { return; } - await this.deleteSessions(keys); + const rowsByKey = new Map(this.result?.sessions.map((row) => [row.key, row]) ?? []); + // Only current row state may opt into write-scoped archive deletion. + // Unknown selections stay unflagged and therefore admin-only. + await this.deleteSessions(keys.map((key) => rowsByKey.get(key) ?? { key })); } private async deleteSessions( - keys: string[], - options: { deleteTranscript?: boolean; archivedOnly?: boolean } = {}, + rows: SessionDeleteRow[], + options: { deleteTranscript?: boolean } = {}, ) { - if (keys.length === 0 || this.loading || this.sessionMutationPending) { + if (rows.length === 0 || this.loading || this.sessionMutationPending) { return; } const scope = this.captureRequestScope(); @@ -710,10 +715,11 @@ class SessionsPage extends OpenClawLightDomElement { this.sessionMutationPending = true; try { const result = await scope.sessions.deleteMany( - keys.map((key) => ({ - key, - agentId: this.sessionAgentId(key, scope.context), + rows.map((row) => ({ + key: row.key, + agentId: this.sessionAgentId(row.key, scope.context), ...options, + ...(row.archived === true ? { archivedOnly: true } : {}), })), ); if (!this.isRequestScopeCurrent(scope)) { @@ -824,14 +830,16 @@ class SessionsPage extends OpenClawLightDomElement { } return; } - const keys = rows.filter((row) => row.archived === true).map((row) => row.key); + const archivedRows = rows.filter((row) => row.archived === true); if ( - keys.length === 0 || - !window.confirm(t("sessionsView.deleteAllArchivedConfirm", { count: String(keys.length) })) + archivedRows.length === 0 || + !window.confirm( + t("sessionsView.deleteAllArchivedConfirm", { count: String(archivedRows.length) }), + ) ) { return; } - await this.deleteSessions(keys, { deleteTranscript: true, archivedOnly: true }); + await this.deleteSessions(archivedRows, { deleteTranscript: true }); } private async deleteSessionFromMenu(row: GatewaySessionRow) { @@ -839,7 +847,7 @@ class SessionsPage extends OpenClawLightDomElement { if (!window.confirm(t("sessionsView.deleteSessionConfirm", { session: label }))) { return; } - await this.deleteSessions([row.key]); + await this.deleteSessions([row]); } private async stopCloudWorker(row: GatewaySessionRow) {