mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
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:
parent
9133d552ac
commit
354a151544
11 changed files with 188 additions and 17 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue