feat(gateway): archive-gated session deletes give Android delete parity (#101522)

sessions.delete gains an explicit archivedOnly param (additive protocol
schema change, Swift models regenerated): with archivedOnly=true, the
dispatcher grants operator.write and the handler requires the target to
already be archived (archive-then-delete keeps destructive intent a
two-step action). Without the flag nothing changes: deletes require
operator.admin, so internal fallback/synthetic dispatch, subagent cleanup,
and CLI minting keep their admin contracts, and the session-kill HTTP
endpoint pins admin explicitly since it terminates live runs.

Android sends archivedOnly and offers Delete only on archived rows, where
its bounded (non-admin) operator session is now authorized; active rows
archive first. iOS/web connect with admin and keep unrestricted deletes.

Refs #100712
This commit is contained in:
Peter Steinberger 2026-07-07 10:52:53 +01:00 committed by GitHub
parent 9133d552ac
commit 354a151544
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 188 additions and 17 deletions

View file

@ -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",

View file

@ -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)

View file

@ -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.
}
}
}

View file

@ -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"
}
}

View file

@ -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 },
);

View file

@ -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", {

View file

@ -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<string> = 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);

View file

@ -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" },

View file

@ -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,

View file

@ -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",

View file

@ -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;