diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index a4e24bf477a..933ab56dc19 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -2883,7 +2883,7 @@ }, { "kind": "conditional-branch", - "line": 673, + "line": 672, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "Group name", "surface": "android", @@ -2891,7 +2891,7 @@ }, { "kind": "conditional-branch", - "line": 673, + "line": 672, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "Name", "surface": "android", @@ -2899,7 +2899,7 @@ }, { "kind": "ui-call", - "line": 683, + "line": 682, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "Cancel", "surface": "android", @@ -2907,7 +2907,7 @@ }, { "kind": "conditional-branch", - "line": 765, + "line": 764, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "No sessions yet", "surface": "android", @@ -2915,7 +2915,7 @@ }, { "kind": "conditional-branch", - "line": 766, + "line": 765, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "No current session", "surface": "android", @@ -2923,7 +2923,7 @@ }, { "kind": "conditional-branch", - "line": 767, + "line": 766, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "No archived sessions", "surface": "android", @@ -2931,7 +2931,7 @@ }, { "kind": "conditional-branch", - "line": 773, + "line": 772, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "Start a new conversation and it will show up here.", "surface": "android", @@ -2939,7 +2939,7 @@ }, { "kind": "conditional-branch", - "line": 774, + "line": 773, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "Open Chat to start or resume the current session.", "surface": "android", @@ -2947,7 +2947,7 @@ }, { "kind": "conditional-branch", - "line": 775, + "line": 774, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt", "source": "Archived sessions will show up here.", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index f5cceac66da..5f7471f8f26 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -458,6 +458,9 @@ class ChatController internal constructor( buildJsonObject { put("key", JsonPrimitive(sessionKey)) put("deleteTranscript", JsonPrimitive(true)) + // archive-then-delete: the bounded operator session lacks admin, and + // the gateway grants write-scope deletes only for archived sessions. + put("archivedOnly", JsonPrimitive(true)) } requestGateway("sessions.delete", params.toString()) fallBackFromRetiredActiveSession(sessionKey) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt index e32a71a8278..678554cc101 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt @@ -591,10 +591,9 @@ private fun SessionRow( menuExpanded = false onSetArchived(true) } - SessionMenuItem("Delete…") { - menuExpanded = false - onDelete() - } + // Delete is archive-gated: the bounded operator session lacks + // operator.admin, and the gateway only grants write-scope deletes + // for archived sessions. Archived rows keep the Delete item. } } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 94df3671ff7..7082d13eb2b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2979,6 +2979,7 @@ public struct SessionsDeleteParams: Codable, Sendable { public let expectedlifecyclerevision: String? public let expectedsessionupdatedat: Double? public let emitlifecyclehooks: Bool? + public let archivedonly: Bool? public init( key: String, @@ -2987,7 +2988,8 @@ public struct SessionsDeleteParams: Codable, Sendable { expectedsessionid: String? = nil, expectedlifecyclerevision: String? = nil, expectedsessionupdatedat: Double? = nil, - emitlifecyclehooks: Bool?) + emitlifecyclehooks: Bool?, + archivedonly: Bool?) { self.key = key self.agentid = agentid @@ -2996,6 +2998,7 @@ public struct SessionsDeleteParams: Codable, Sendable { self.expectedlifecyclerevision = expectedlifecyclerevision self.expectedsessionupdatedat = expectedsessionupdatedat self.emitlifecyclehooks = emitlifecyclehooks + self.archivedonly = archivedonly } private enum CodingKeys: String, CodingKey { @@ -3006,6 +3009,7 @@ public struct SessionsDeleteParams: Codable, Sendable { case expectedlifecyclerevision = "expectedLifecycleRevision" case expectedsessionupdatedat = "expectedSessionUpdatedAt" case emitlifecyclehooks = "emitLifecycleHooks" + case archivedonly = "archivedOnly" } } diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index f8fe60246a2..0fc13a74381 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -424,6 +424,12 @@ export const SessionsDeleteParamsSchema = Type.Object( expectedSessionUpdatedAt: Type.Optional(Type.Number({ minimum: 0 })), // Internal control: when false, still unbind thread bindings but skip hook emission. emitLifecycleHooks: Type.Optional(Type.Boolean()), + /** + * Restricts the delete to already-archived sessions (archive-then-delete). + * operator.write callers must set this; deletes without it require + * operator.admin. + */ + archivedOnly: Type.Optional(Type.Boolean()), }, { additionalProperties: false }, ); diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 18702b2bd8b..cb404913fb0 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -247,6 +247,70 @@ describe("method scope resolution", () => { ]); }); + it("grants write-scope sessions.delete only with the archivedOnly opt-in", () => { + // Internal callers (subagent cleanup, fallback synthetic dispatch, CLI + // minting) never set archivedOnly and keep requiring admin; the handler + // enforces that archivedOnly targets are actually archived. + expect(resolveLeastPrivilegeOperatorScopesForMethod("sessions.delete")).toEqual([ + "operator.admin", + ]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.delete", { + key: "agent:main:old", + deleteTranscript: true, + }), + ).toEqual(["operator.admin"]); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.delete", { + key: "agent:main:old", + archivedOnly: true, + }), + ).toEqual(["operator.write"]); + const archivedParams = { key: "agent:main:old", archivedOnly: true }; + expect( + authorizeOperatorScopesForMethod("sessions.delete", ["operator.write"], archivedParams), + ).toEqual({ allowed: true }); + expect( + authorizeOperatorScopesForMethod("sessions.delete", ["operator.read"], archivedParams), + ).toEqual({ allowed: false, missingScope: "operator.write" }); + expect( + authorizeOperatorScopesForMethod("sessions.delete", ["operator.write"], { + key: "agent:main:old", + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + expect( + authorizeOperatorScopesForMethod("sessions.delete", ["operator.write"], { + key: "agent:main:old", + archivedOnly: "yes", + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + // Internal-only controls must not ride along on the write-scope path. + expect( + authorizeOperatorScopesForMethod("sessions.delete", ["operator.write"], { + key: "agent:main:old", + archivedOnly: true, + emitLifecycleHooks: false, + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + expect( + authorizeOperatorScopesForMethod("sessions.delete", ["operator.write"], { + key: "agent:main:old", + archivedOnly: true, + expectedSessionId: "sess-1", + }), + ).toEqual({ allowed: false, missingScope: "operator.admin" }); + expect( + resolveLeastPrivilegeOperatorScopesForMethod("sessions.delete", { + key: "agent:main:old", + archivedOnly: true, + emitLifecycleHooks: false, + }), + ).toEqual(["operator.admin"]); + expect(authorizeOperatorScopesForMethod("sessions.delete", ["operator.admin"])).toEqual({ + allowed: true, + }); + }); + it("falls back to broad operator scopes when a dynamic session action is not locally registered", () => { expect( resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction", { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 0a55b4ad077..23d488fbf29 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -155,9 +155,41 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod( if (method === "sessions.patch") { return resolveSessionsPatchRequiredScopes(params); } + if (method === "sessions.delete") { + return resolveSessionsDeleteRequiredScopes(params); + } return [WRITE_SCOPE]; } +/** + * sessions.delete params a write-scoped archive-then-delete request may carry. + * Internal controls (emitLifecycleHooks, expected* CAS guards) stay admin-only + * — fail closed on anything outside this set. + */ +const SESSIONS_DELETE_WRITE_SCOPE_FIELDS: ReadonlySet = new Set([ + "key", + "agentId", + "deleteTranscript", + "archivedOnly", +]); + +function resolveSessionsDeleteRequiredScopes(params: unknown): OperatorScope[] { + // archivedOnly is the explicit archive-then-delete opt-in: write scope may + // delete only already-archived sessions (the handler enforces the state, + // both pre-lock and under the lifecycle lock). Everything else — including + // internal fallback/synthetic dispatch, which never sets the flag, and any + // request carrying internal-only params — keeps requiring admin. + if (!params || typeof params !== "object" || Array.isArray(params)) { + return [ADMIN_SCOPE]; + } + const record = params as { archivedOnly?: unknown }; + if (record.archivedOnly !== true) { + return [ADMIN_SCOPE]; + } + const safeOnly = Object.keys(params).every((key) => SESSIONS_DELETE_WRITE_SCOPE_FIELDS.has(key)); + return safeOnly ? [WRITE_SCOPE] : [ADMIN_SCOPE]; +} + function findMissingOperatorScope( requiredScopes: readonly OperatorScope[], scopes: readonly string[], @@ -200,6 +232,13 @@ export function authorizeOperatorScopesForMethod( ); return missingScope ? { allowed: false, missingScope } : { allowed: true }; } + if (method === "sessions.delete") { + const missingScope = findMissingOperatorScope( + resolveSessionsDeleteRequiredScopes(params), + scopes, + ); + return missingScope ? { allowed: false, missingScope } : { allowed: true }; + } const registeredScopes = resolveSessionActionRegisteredScopes(params); if (!registeredScopes && params && typeof params === "object" && !Array.isArray(params)) { const pluginId = normalizeSessionActionParam((params as { pluginId?: unknown }).pluginId); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 1988dd7b3fc..2fe240f8cd1 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -173,7 +173,10 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "sessions.pluginPatch", scope: "operator.admin" }, { name: "sessions.cleanup", scope: "operator.admin" }, { name: "sessions.reset", scope: "operator.admin" }, - { name: "sessions.delete", scope: "operator.admin" }, + // State-aware: write scope may delete already-archived sessions + // (archive-then-delete); the handler enforces the archived requirement and + // admin keeps unrestricted delete. Policy in method-scopes.ts + handler. + { name: "sessions.delete", scope: "dynamic" }, { name: "sessions.compact", scope: "operator.admin" }, { name: "last-heartbeat", scope: "operator.read" }, { name: "set-heartbeats", scope: "operator.admin" }, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 065f854d807..cc5ab2f55fe 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -2278,6 +2278,19 @@ export const sessionsHandlers: GatewayRequestHandlers = { const initialDeleteEntry = loadSessionEntry(key, { agentId: requestedAgentId, }).entry; + // archivedOnly is the archive-then-delete contract: the dispatcher grants + // it to write-scope operators, so the target must actually be archived. + if (p.archivedOnly === true && initialDeleteEntry?.archivedAt === undefined) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} is not archived. Archive it first, then delete it.`, + ), + ); + return; + } const expectedSessionId = p.expectedSessionId?.trim(); const expectedLifecycleRevision = p.expectedLifecycleRevision?.trim(); const expectedSessionUpdatedAt = p.expectedSessionUpdatedAt; @@ -2370,6 +2383,19 @@ export const sessionsHandlers: GatewayRequestHandlers = { if (rejectExpectedSessionMismatch(entry)) { return undefined; } + // Recheck under the lifecycle lock: an unarchive racing the pre-lock + // check must not let an archive-gated delete remove an active session. + if (p.archivedOnly === true && entry?.archivedAt === undefined) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Session ${key} is not archived. Archive it first, then delete it.`, + ), + ); + return undefined; + } if ( rejectPluginRuntimeDeleteMismatch({ client, diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index c666f3a2270..0833d5cef16 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -742,6 +742,31 @@ test("write-scoped operators manage chat organization but not admin session sett "agent:main:topic-b", ]); + const unflaggedDeleteDenied = await rpcReq(ws, "sessions.delete", { + key: "agent:main:topic-b", + }); + expect(unflaggedDeleteDenied.ok).toBe(false); + expect(unflaggedDeleteDenied.error?.message).toContain("missing scope: operator.admin"); + + const activeDeleteDenied = await rpcReq(ws, "sessions.delete", { + key: "agent:main:topic-a", + archivedOnly: true, + }); + expect(activeDeleteDenied.ok).toBe(false); + expect(activeDeleteDenied.error?.message).toContain("Archive it first"); + + const archivedDeleted = await rpcReq<{ ok: true }>(ws, "sessions.delete", { + key: "agent:main:topic-b", + archivedOnly: true, + }); + expect(archivedDeleted.ok).toBe(true); + const archivedAfterDelete = await rpcReq<{ sessions: Array<{ key: string }> }>( + ws, + "sessions.list", + { archived: true }, + ); + expect(archivedAfterDelete.payload?.sessions).toEqual([]); + const adminFieldDenied = await rpcReq(ws, "sessions.patch", { key: "agent:main:topic-a", sendPolicy: "deny", diff --git a/src/gateway/session-kill-http.ts b/src/gateway/session-kill-http.ts index 7508f5cb798..a36ce2e8ac9 100644 --- a/src/gateway/session-kill-http.ts +++ b/src/gateway/session-kill-http.ts @@ -15,7 +15,7 @@ import { authorizeGatewayHttpRequestOrReply, resolveTrustedHttpOperatorScopes, } from "./http-utils.js"; -import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; +import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "./method-scopes.js"; import { loadSessionEntry } from "./session-utils.js"; type SessionKeyPathResolution = @@ -79,7 +79,9 @@ export async function handleSessionKillHttpRequest( } const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); - const scopeAuth = authorizeOperatorScopesForMethod("sessions.delete", requestedScopes); + // Run kills stay admin-only: sessions.delete is dynamic (write may delete + // archived sessions via RPC), but this endpoint terminates live runs. + const scopeAuth = authorizeOperatorScopesForRequiredScope(ADMIN_SCOPE, requestedScopes); if (!scopeAuth.allowed) { sendMissingScopeForbidden(res, scopeAuth.missingScope); return true;