diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index a04508496780..07708e107148 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -378,6 +378,7 @@ enum class GatewayMethod( MigrationsMemoryPlan("migrations.memory.plan"), MigrationsMemoryApply("migrations.memory.apply"), UiCommand("ui.command"), + ApprovalHistory("approval.history"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index d8456a399244..2fdeb4b85562 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11629,6 +11629,8 @@ public struct AllowedApprovalSnapshot: Codable, Sendable { public let expiresatms: Int public let presentation: ApprovalPresentation public let resolvedatms: Int + public let source: [String: AnyCodable]? + public let resolver: [String: AnyCodable]? public let status: String public let decision: ApprovalAllowDecision public let reason: ApprovalAllowedReason @@ -11640,6 +11642,8 @@ public struct AllowedApprovalSnapshot: Codable, Sendable { expiresatms: Int, presentation: ApprovalPresentation, resolvedatms: Int, + source: [String: AnyCodable]? = nil, + resolver: [String: AnyCodable]? = nil, status: String, decision: ApprovalAllowDecision, reason: ApprovalAllowedReason) @@ -11650,6 +11654,8 @@ public struct AllowedApprovalSnapshot: Codable, Sendable { self.expiresatms = expiresatms self.presentation = presentation self.resolvedatms = resolvedatms + self.source = source + self.resolver = resolver self.status = status self.decision = decision self.reason = reason @@ -11662,6 +11668,8 @@ public struct AllowedApprovalSnapshot: Codable, Sendable { case expiresatms = "expiresAtMs" case presentation case resolvedatms = "resolvedAtMs" + case source + case resolver case status case decision case reason @@ -11675,6 +11683,8 @@ public struct DeniedApprovalSnapshot: Codable, Sendable { public let expiresatms: Int public let presentation: ApprovalPresentation public let resolvedatms: Int + public let source: [String: AnyCodable]? + public let resolver: [String: AnyCodable]? public let status: String public let decision: String public let reason: ApprovalDeniedReason @@ -11686,6 +11696,8 @@ public struct DeniedApprovalSnapshot: Codable, Sendable { expiresatms: Int, presentation: ApprovalPresentation, resolvedatms: Int, + source: [String: AnyCodable]? = nil, + resolver: [String: AnyCodable]? = nil, status: String, decision: String, reason: ApprovalDeniedReason) @@ -11696,6 +11708,8 @@ public struct DeniedApprovalSnapshot: Codable, Sendable { self.expiresatms = expiresatms self.presentation = presentation self.resolvedatms = resolvedatms + self.source = source + self.resolver = resolver self.status = status self.decision = decision self.reason = reason @@ -11708,6 +11722,8 @@ public struct DeniedApprovalSnapshot: Codable, Sendable { case expiresatms = "expiresAtMs" case presentation case resolvedatms = "resolvedAtMs" + case source + case resolver case status case decision case reason @@ -11721,6 +11737,8 @@ public struct ExpiredApprovalSnapshot: Codable, Sendable { public let expiresatms: Int public let presentation: ApprovalPresentation public let resolvedatms: Int + public let source: [String: AnyCodable]? + public let resolver: [String: AnyCodable]? public let status: String public let reason: ApprovalExpiredReason @@ -11731,6 +11749,8 @@ public struct ExpiredApprovalSnapshot: Codable, Sendable { expiresatms: Int, presentation: ApprovalPresentation, resolvedatms: Int, + source: [String: AnyCodable]? = nil, + resolver: [String: AnyCodable]? = nil, status: String, reason: ApprovalExpiredReason) { @@ -11740,6 +11760,8 @@ public struct ExpiredApprovalSnapshot: Codable, Sendable { self.expiresatms = expiresatms self.presentation = presentation self.resolvedatms = resolvedatms + self.source = source + self.resolver = resolver self.status = status self.reason = reason } @@ -11751,6 +11773,8 @@ public struct ExpiredApprovalSnapshot: Codable, Sendable { case expiresatms = "expiresAtMs" case presentation case resolvedatms = "resolvedAtMs" + case source + case resolver case status case reason } @@ -11763,6 +11787,8 @@ public struct CancelledApprovalSnapshot: Codable, Sendable { public let expiresatms: Int public let presentation: ApprovalPresentation public let resolvedatms: Int + public let source: [String: AnyCodable]? + public let resolver: [String: AnyCodable]? public let status: String public let reason: ApprovalCancelledReason @@ -11773,6 +11799,8 @@ public struct CancelledApprovalSnapshot: Codable, Sendable { expiresatms: Int, presentation: ApprovalPresentation, resolvedatms: Int, + source: [String: AnyCodable]? = nil, + resolver: [String: AnyCodable]? = nil, status: String, reason: ApprovalCancelledReason) { @@ -11782,6 +11810,8 @@ public struct CancelledApprovalSnapshot: Codable, Sendable { self.expiresatms = expiresatms self.presentation = presentation self.resolvedatms = resolvedatms + self.source = source + self.resolver = resolver self.status = status self.reason = reason } @@ -11793,6 +11823,8 @@ public struct CancelledApprovalSnapshot: Codable, Sendable { case expiresatms = "expiresAtMs" case presentation case resolvedatms = "resolvedAtMs" + case source + case resolver case status case reason } @@ -11826,6 +11858,46 @@ public struct ApprovalGetResult: Codable, Sendable { } } +public struct ApprovalHistoryParams: Codable, Sendable { + public let cursor: String? + public let limit: Int? + public let kind: ApprovalKind? + + public init( + cursor: String? = nil, + limit: Int? = nil, + kind: ApprovalKind? = nil) + { + self.cursor = cursor + self.limit = limit + self.kind = kind + } + + private enum CodingKeys: String, CodingKey { + case cursor + case limit + case kind + } +} + +public struct ApprovalHistoryResult: Codable, Sendable { + public let items: [TerminalApprovalSnapshot] + public let nextcursor: String? + + public init( + items: [TerminalApprovalSnapshot], + nextcursor: String? = nil) + { + self.items = items + self.nextcursor = nextcursor + } + + private enum CodingKeys: String, CodingKey { + case items + case nextcursor = "nextCursor" + } +} + public struct ApprovalResolveParams: Codable, Sendable { public let id: String public let kind: ApprovalKind diff --git a/packages/gateway-protocol/src/approval-result-validators.ts b/packages/gateway-protocol/src/approval-result-validators.ts index 6cbbdee3a83f..3afadb5326df 100644 --- a/packages/gateway-protocol/src/approval-result-validators.ts +++ b/packages/gateway-protocol/src/approval-result-validators.ts @@ -3,6 +3,8 @@ import { type ApprovalDecision, type ApprovalGetResult, ApprovalGetResultSchema, + type ApprovalHistoryResult, + ApprovalHistoryResultSchema, type ApprovalPresentation, type ApprovalResolveResult, ApprovalResolveResultSchema, @@ -12,10 +14,12 @@ import { export type { ApprovalDecision, ApprovalGetResult, + ApprovalHistoryResult, ApprovalPresentation, ApprovalResolveResult, ApprovalSnapshot, }; export const validateApprovalGetResult = lazyCompile(ApprovalGetResultSchema); +export const validateApprovalHistoryResult = lazyCompile(ApprovalHistoryResultSchema); export const validateApprovalResolveResult = lazyCompile(ApprovalResolveResultSchema); diff --git a/packages/gateway-protocol/src/approvals-validators.test.ts b/packages/gateway-protocol/src/approvals-validators.test.ts index 37d92288eaa8..58476cddd832 100644 --- a/packages/gateway-protocol/src/approvals-validators.test.ts +++ b/packages/gateway-protocol/src/approvals-validators.test.ts @@ -3,6 +3,8 @@ import { validateApprovalAllowDecision, validateApprovalGetParams, validateApprovalGetResult, + validateApprovalHistoryParams, + validateApprovalHistoryResult, validateApprovalDecision, validateApprovalKind, validateApprovalPresentation, @@ -217,6 +219,32 @@ describe("unified approval protocol validators", () => { ).toBe(false); }); + it("validates terminal history pages and optional attribution", () => { + const terminal = { + ...pluginRecord, + status: "denied", + decision: "deny", + resolvedAtMs: pluginRecord.createdAtMs + 1_000, + reason: "user", + source: { agentId: "release", sessionKey: "agent:release:main" }, + resolver: { kind: "device", id: "reviewer-device" }, + } as const; + + expect(validateApprovalHistoryParams({})).toBe(true); + expect(validateApprovalHistoryParams({ cursor: "cursor", limit: 50, kind: "plugin" })).toBe( + true, + ); + expect(validateApprovalHistoryParams({ limit: 0 })).toBe(false); + expect(validateApprovalHistoryParams({ limit: 101 })).toBe(false); + expect(validateApprovalHistoryParams({ kind: "tool" })).toBe(false); + + expect(validateApprovalHistoryResult({ items: [terminal], nextCursor: "next" })).toBe(true); + expect(validateApprovalHistoryResult({ items: [{ ...execRecord, status: "pending" }] })).toBe( + false, + ); + expect(validateApprovalHistoryResult({ items: [terminal], extra: true })).toBe(false); + }); + it("returns the canonical recorded snapshot to losing resolvers", () => { const recorded = { ...execRecord, diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 1a1a2237cd97..921d722a900d 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1,7 +1,10 @@ export * from "./clawhub-trust-error-details.js"; export * from "./terminal-validators.js"; -export { validateApprovalGetResult } from "./approval-result-validators.js"; -export { validateApprovalResolveResult } from "./approval-result-validators.js"; +export { + validateApprovalGetResult, + validateApprovalHistoryResult, + validateApprovalResolveResult, +} from "./approval-result-validators.js"; import type { ValidationError } from "./validation-errors.js"; export { formatValidationErrors, type ValidationError } from "./validation-errors.js"; import { lazyCompile } from "./protocol-validator.js"; @@ -148,6 +151,8 @@ import { ApprovalDecisionSchema, ApprovalGetParamsSchema, ApprovalGetResultSchema, + ApprovalHistoryParamsSchema, + ApprovalHistoryResultSchema, ApprovalKindSchema, ApprovalPresentationSchema, ApprovalResolveParamsSchema, @@ -797,6 +802,7 @@ export const validateCancelledApprovalSnapshot = lazyCompile(CancelledApprovalSn export const validateApprovalSnapshot = lazyCompile(ApprovalSnapshotSchema); export const validateTerminalApprovalSnapshot = lazyCompile(TerminalApprovalSnapshotSchema); export const validateApprovalGetParams = lazyCompile(ApprovalGetParamsSchema); +export const validateApprovalHistoryParams = lazyCompile(ApprovalHistoryParamsSchema); export const validateApprovalResolveParams = lazyCompile(ApprovalResolveParamsSchema); export const validateExecApprovalsGetParams = lazyCompile(ExecApprovalsGetParamsSchema); export const validateExecApprovalsSetParams = lazyCompile(ExecApprovalsSetParamsSchema); @@ -1240,6 +1246,8 @@ export { TerminalApprovalSnapshotSchema, ApprovalGetParamsSchema, ApprovalGetResultSchema, + ApprovalHistoryParamsSchema, + ApprovalHistoryResultSchema, ApprovalResolveParamsSchema, ApprovalResolveResultSchema, SessionApprovalEventSchema, @@ -1622,6 +1630,8 @@ export type { TerminalApprovalSnapshot, ApprovalGetParams, ApprovalGetResult, + ApprovalHistoryParams, + ApprovalHistoryResult, ApprovalResolveParams, ApprovalResolveResult, SessionApprovalEvent, diff --git a/packages/gateway-protocol/src/schema/approvals.ts b/packages/gateway-protocol/src/schema/approvals.ts index 83bcc0a9581a..619a56219c83 100644 --- a/packages/gateway-protocol/src/schema/approvals.ts +++ b/packages/gateway-protocol/src/schema/approvals.ts @@ -141,8 +141,27 @@ const ApprovalRecordCommonFields = { presentation: ApprovalPresentationSchema, }; +/** Reviewer-safe origin attribution for terminal approval history. */ +const ApprovalHistorySourceAttributionSchema = closedObject({ + agentId: Type.Optional(NonEmptyString), + sessionKey: Type.Optional(NonEmptyString), +}); + +/** Reviewer attribution recorded by the durable approval ledger. */ +const ApprovalHistoryResolverAttributionSchema = closedObject({ + kind: Type.Union([ + Type.Literal("device"), + Type.Literal("channel"), + Type.Literal("runtime"), + Type.Literal("system"), + ]), + id: Type.Optional(NonEmptyString), +}); + const ApprovalResolutionFields = { resolvedAtMs: Type.Integer({ minimum: 0 }), + source: Type.Optional(ApprovalHistorySourceAttributionSchema), + resolver: Type.Optional(ApprovalHistoryResolverAttributionSchema), }; /** Approval that has not yet accepted a reviewer decision. */ @@ -208,6 +227,19 @@ export const ApprovalGetParamsSchema = closedObject({ id: ApprovalRecordCommonFi /** Current durable state for one authorized approval lookup. */ export const ApprovalGetResultSchema = closedObject({ approval: ApprovalSnapshotSchema }); +/** Cursor-based query for the retained terminal approval ledger. */ +export const ApprovalHistoryParamsSchema = closedObject({ + cursor: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), + kind: Type.Optional(ApprovalKindSchema), +}); + +/** Newest-first page from the retained terminal approval ledger. */ +export const ApprovalHistoryResultSchema = closedObject({ + items: Type.Array(TerminalApprovalSnapshotSchema), + nextCursor: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })), +}); + /** Reviewer decision for one approval identified by its exact full id. */ export const ApprovalResolveParamsSchema = closedObject({ id: ApprovalRecordCommonFields.id, @@ -270,6 +302,8 @@ export type PendingApprovalSnapshot = Static; export type ApprovalGetParams = Static; export type ApprovalGetResult = Static; +export type ApprovalHistoryParams = Static; +export type ApprovalHistoryResult = Static; export type ApprovalResolveParams = Static; export type ApprovalResolveResult = Static; export type AllowedApprovalSnapshot = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index f4bd5113d1fe..3b568f98fc6a 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -100,6 +100,8 @@ import { ApprovalExpiredReasonSchema, ApprovalGetParamsSchema, ApprovalGetResultSchema, + ApprovalHistoryParamsSchema, + ApprovalHistoryResultSchema, ApprovalKindSchema, ApprovalPresentationSchema, ApprovalResolveParamsSchema, @@ -893,6 +895,8 @@ export const ProtocolSchemas = { TerminalApprovalSnapshot: TerminalApprovalSnapshotSchema, ApprovalGetParams: ApprovalGetParamsSchema, ApprovalGetResult: ApprovalGetResultSchema, + ApprovalHistoryParams: ApprovalHistoryParamsSchema, + ApprovalHistoryResult: ApprovalHistoryResultSchema, ApprovalResolveParams: ApprovalResolveParamsSchema, ApprovalResolveResult: ApprovalResolveResultSchema, PendingSessionApprovalEvent: PendingSessionApprovalEventSchema, diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index ff3549e2dc72..d33c56ea3342 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -492,6 +492,7 @@ describe("operator scope authorization", () => { it.each([ "approval.get", + "approval.history", "approval.resolve", "exec.approval.get", "exec.approval.list", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 4f2887f919ee..e4cfe28e82b2 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -366,6 +366,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "migrations.memory.plan", scope: "operator.admin" }, { name: "migrations.memory.apply", scope: "operator.admin", controlPlaneWrite: true }, { name: "ui.command", scope: "operator.write" }, + { name: "approval.history", scope: "operator.approvals" }, ] as const; const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap = new Map( diff --git a/src/gateway/operator-approval-store.test.ts b/src/gateway/operator-approval-store.test.ts index 585f6608817b..ef5ca86d738d 100644 --- a/src/gateway/operator-approval-store.test.ts +++ b/src/gateway/operator-approval-store.test.ts @@ -26,6 +26,7 @@ import { getOperatorApprovalDetailedByLocator, insertOperatorApproval, listPendingOperatorApprovals, + listTerminalOperatorApprovals, OPERATOR_APPROVAL_MAX_AUDIENCE_SESSION_KEYS, pruneTerminalOperatorApprovals, resolveOperatorApproval, @@ -43,7 +44,9 @@ function getOperatorApproval(params: Parameters { ]); }); + it("lists terminal history newest-first with kind filtering and keyset pagination", () => { + const databaseOptions = createDatabaseOptions(); + const entries: NewOperatorApproval[] = [ + approval("exec-old", { createdAtMs: 1_000 }), + approval("plugin-new", { kind: "plugin", createdAtMs: 1_001 }), + approval("system-middle", { + kind: "system-agent", + presentation: { + kind: "system-agent", + title: "Approve system change", + description: "Apply the proposed system-agent change.", + proposalHash: "a".repeat(64), + agentId: "main", + allowedDecisions: ["allow-once", "deny"], + }, + createdAtMs: 1_002, + }), + approval("still-pending", { createdAtMs: 1_003 }), + ]; + for (const entry of entries) { + expect(insertOperatorApproval({ approval: entry, databaseOptions })).toMatchObject({ + outcome: "inserted", + }); + } + for (const [id, nowMs] of [ + ["exec-old", 2_000], + ["system-middle", 3_000], + ["plugin-new", 3_000], + ] as const) { + expect( + resolveOperatorApproval({ + id, + decision: "deny", + resolver: { kind: "device", id: "reviewer-device" }, + nowMs, + databaseOptions, + }), + ).toMatchObject({ outcome: "resolved" }); + } + + const firstPage = listTerminalOperatorApprovals({ limit: 2, nowMs: 3_000, databaseOptions }); + expect(firstPage.records.map((record) => record.id)).toEqual(["system-middle", "plugin-new"]); + expect(firstPage.nextCursor).toEqual(expect.any(String)); + + const secondPage = listTerminalOperatorApprovals({ + cursor: firstPage.nextCursor, + limit: 2, + nowMs: 3_000, + databaseOptions, + }); + expect(secondPage.records.map((record) => record.id)).toEqual(["exec-old"]); + expect(secondPage.nextCursor).toBeUndefined(); + + expect( + listTerminalOperatorApprovals({ kind: "plugin", nowMs: 3_000, databaseOptions }).records.map( + (record) => record.id, + ), + ).toEqual(["plugin-new"]); + }); + + it("excludes terminal rows resolved before the 30-day retention cutoff", () => { + const databaseOptions = createDatabaseOptions(); + const day = 24 * 60 * 60_000; + const now = 100 * day; + expect( + insertOperatorApproval({ + approval: approval("old", { createdAtMs: 1_000, expiresAtMs: now }), + databaseOptions, + }), + ).toMatchObject({ outcome: "inserted" }); + expect( + insertOperatorApproval({ + approval: approval("recent", { createdAtMs: now - 2 * day, expiresAtMs: now }), + databaseOptions, + }), + ).toMatchObject({ outcome: "inserted" }); + // Resolve one row 40 days ago (past the window) and one 1 day ago (inside). + expect( + resolveOperatorApproval({ + id: "old", + decision: "deny", + resolver: { kind: "device", id: "reviewer-device" }, + nowMs: now - 40 * day, + databaseOptions, + }), + ).toMatchObject({ outcome: "resolved" }); + expect( + resolveOperatorApproval({ + id: "recent", + decision: "deny", + resolver: { kind: "device", id: "reviewer-device" }, + nowMs: now - day, + databaseOptions, + }), + ).toMatchObject({ outcome: "resolved" }); + + expect( + listTerminalOperatorApprovals({ nowMs: now, databaseOptions }).records.map( + (record) => record.id, + ), + ).toEqual(["recent"]); + }); + it("filters an audience before applying the replay limit across scan pages", () => { const databaseOptions = createDatabaseOptions(); for (let index = 0; index < 256; index += 1) { diff --git a/src/gateway/operator-approval-store.ts b/src/gateway/operator-approval-store.ts index 937e1e74330c..ca6fc7e32e45 100644 --- a/src/gateway/operator-approval-store.ts +++ b/src/gateway/operator-approval-store.ts @@ -28,6 +28,8 @@ const OPERATOR_APPROVAL_TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60_000; export const OPERATOR_APPROVAL_MAX_AUDIENCE_SESSION_KEYS = 64; const OPERATOR_APPROVAL_PENDING_SCAN_PAGE_SIZE = 256; const OPERATOR_APPROVAL_MAX_LIST_LIMIT = 1_001; +const OPERATOR_APPROVAL_HISTORY_DEFAULT_LIMIT = 50; +const OPERATOR_APPROVAL_HISTORY_MAX_LIMIT = 100; export type OperatorApprovalKind = "exec" | "plugin" | "system-agent"; export type OperatorApprovalStatus = "pending" | "allowed" | "denied" | "expired" | "cancelled"; @@ -142,6 +144,23 @@ type TerminalizeOperatorApprovalsResult = { type OperatorApprovalDatabase = Pick; type OperatorApprovalRow = Selectable; +type OperatorApprovalHistoryCursor = { + resolvedAtMs: number; + id: string; +}; + +export class OperatorApprovalHistoryCursorError extends Error { + constructor() { + super("invalid operator approval history cursor"); + this.name = "OperatorApprovalHistoryCursorError"; + } +} + +type ListTerminalOperatorApprovalsResult = { + records: OperatorApprovalRecord[]; + nextCursor?: string; +}; + const OPERATOR_APPROVAL_DECISIONS = new Set([ "allow-once", "allow-always", @@ -215,6 +234,38 @@ function requireApprovalId(value: string): string { return value; } +function encodeOperatorApprovalHistoryCursor(cursor: OperatorApprovalHistoryCursor): string { + return Buffer.from(JSON.stringify({ v: 1, ...cursor }), "utf8").toString("base64url"); +} + +function decodeOperatorApprovalHistoryCursor(raw: string): OperatorApprovalHistoryCursor { + try { + const parsed: unknown = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + !("v" in parsed) || + parsed.v !== 1 || + !("resolvedAtMs" in parsed) || + typeof parsed.resolvedAtMs !== "number" || + !Number.isSafeInteger(parsed.resolvedAtMs) || + parsed.resolvedAtMs < 0 || + !("id" in parsed) || + typeof parsed.id !== "string" || + !isWellFormedApprovalId(parsed.id) + ) { + throw new OperatorApprovalHistoryCursorError(); + } + return { resolvedAtMs: parsed.resolvedAtMs, id: parsed.id }; + } catch (error) { + if (error instanceof OperatorApprovalHistoryCursorError) { + throw error; + } + throw new OperatorApprovalHistoryCursorError(); + } +} + function normalizeStringArray(values: readonly string[] | undefined): string[] { const result: string[] = []; for (const value of values ?? []) { @@ -795,6 +846,87 @@ export function listPendingOperatorApprovals( }, params.databaseOptions); } +export function listTerminalOperatorApprovals( + params: { + cursor?: string; + limit?: number; + kind?: OperatorApprovalKind; + nowMs?: number; + databaseOptions?: OpenClawStateDatabaseOptions; + } = {}, +): ListTerminalOperatorApprovalsResult { + const requestedLimit = Number.isSafeInteger(params.limit) + ? (params.limit ?? OPERATOR_APPROVAL_HISTORY_DEFAULT_LIMIT) + : OPERATOR_APPROVAL_HISTORY_DEFAULT_LIMIT; + const resultLimit = Math.max(1, Math.min(requestedLimit, OPERATOR_APPROVAL_HISTORY_MAX_LIMIT)); + // Enforce the same 30-day retention the UI promises, independent of whether a + // prune has run recently, so history can never surface rows past the window. + const retentionCutoffMs = (params.nowMs ?? Date.now()) - OPERATOR_APPROVAL_TERMINAL_RETENTION_MS; + let cursor = + params.cursor === undefined ? undefined : decodeOperatorApprovalHistoryCursor(params.cursor); + const database = openOpenClawStateDatabase(params.databaseOptions); + const stateDb = getNodeSqliteKysely(database.db); + const records: OperatorApprovalRecord[] = []; + const pageSize = resultLimit + 1; + + // Corrupt rows are skipped through the same decode-and-validate path used by + // point lookups. Continue the keyset scan so one bad row cannot hide later + // valid history. + while (records.length < pageSize) { + const batchLimit = pageSize - records.length; + let query = stateDb + .selectFrom("operator_approvals") + .selectAll() + .where("status", "!=", "pending") + .where("resolved_at_ms", "is not", null) + .where("resolved_at_ms", ">=", retentionCutoffMs) + .orderBy("resolved_at_ms", "desc") + .orderBy("approval_id", "desc") + .limit(batchLimit); + if (params.kind) { + query = query.where("kind", "=", params.kind); + } + if (cursor) { + const pageCursor = cursor; + query = query.where((eb) => + eb.or([ + eb("resolved_at_ms", "<", pageCursor.resolvedAtMs), + eb.and([ + eb("resolved_at_ms", "=", pageCursor.resolvedAtMs), + eb("approval_id", "<", pageCursor.id), + ]), + ]), + ); + } + const rows = executeSqliteQuerySync(database.db, query).rows; + for (const row of rows) { + const record = decodeOperatorApprovalRow(row); + if (record) { + records.push(record); + } + } + const last = rows.at(-1); + if (rows.length < batchLimit || !last || last.resolved_at_ms === null) { + break; + } + cursor = { resolvedAtMs: last.resolved_at_ms, id: last.approval_id }; + } + + const page = records.slice(0, resultLimit); + const last = page.at(-1); + return { + records: page, + ...(records.length > resultLimit && last && last.resolvedAtMs !== null + ? { + nextCursor: encodeOperatorApprovalHistoryCursor({ + resolvedAtMs: last.resolvedAtMs, + id: last.id, + }), + } + : {}), + }; +} + export function resolveOperatorApproval(params: { id: string; decision: OperatorApprovalDecision; diff --git a/src/gateway/operator-approvals-client.e2e.test.ts b/src/gateway/operator-approvals-client.e2e.test.ts index 760ae5b2927a..7f0456363330 100644 --- a/src/gateway/operator-approvals-client.e2e.test.ts +++ b/src/gateway/operator-approvals-client.e2e.test.ts @@ -5,8 +5,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { type ApprovalGetResult, + type ApprovalHistoryResult, type ApprovalResolveResult, validateApprovalGetResult, + validateApprovalHistoryResult, validateApprovalResolveResult, } from "../../packages/gateway-protocol/src/index.js"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; @@ -258,6 +260,9 @@ describe("operator approval gateway client e2e", () => { await expect(underscoped.request("approval.get", { id: approvalId })).rejects.toThrow( "missing scope: operator.approvals", ); + await expect(underscoped.request("approval.history", {})).rejects.toThrow( + "missing scope: operator.approvals", + ); await expect( underscoped.request("approval.resolve", { id: approvalId, kind: "exec", decision: "deny" }), ).rejects.toThrow("missing scope: operator.approvals"); @@ -303,5 +308,11 @@ describe("operator approval gateway client e2e", () => { const terminal = await requester.request("approval.get", { id: approvalId }); expect(validateApprovalGetResult(terminal)).toBe(true); expect(terminal.approval).toEqual(allowResult.approval); + + const history = await reviewer.request("approval.history", { + limit: 10, + }); + expect(validateApprovalHistoryResult(history)).toBe(true); + expect(history.items).toContainEqual(allowResult.approval); }, 120_000); }); diff --git a/src/gateway/server-aux-handlers.ts b/src/gateway/server-aux-handlers.ts index f2267e50ca9d..23b3a070e467 100644 --- a/src/gateway/server-aux-handlers.ts +++ b/src/gateway/server-aux-handlers.ts @@ -537,6 +537,7 @@ export function createGatewayAuxHandlers(params: { loadPluginApprovalHandlers, ), "approval.get": createLazyHandler("approval.get", loadApprovalHandlers), + "approval.history": createLazyHandler("approval.history", loadApprovalHandlers), "approval.resolve": createLazyHandler("approval.resolve", loadApprovalHandlers), "secrets.reload": createLazyHandler("secrets.reload", loadSecretsHandlers), "secrets.resolve": createLazyHandler("secrets.resolve", loadSecretsHandlers), diff --git a/src/gateway/server-aux-methods.ts b/src/gateway/server-aux-methods.ts index 63be277aa65d..49fdc9c46f16 100644 --- a/src/gateway/server-aux-methods.ts +++ b/src/gateway/server-aux-methods.ts @@ -12,6 +12,7 @@ export const GATEWAY_AUX_METHODS = [ "plugin.approval.waitDecision", "plugin.approval.resolve", "approval.get", + "approval.history", "approval.resolve", "secrets.reload", "secrets.resolve", diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 53ccb5e14f1e..97fbfece53d9 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -36,17 +36,19 @@ describe("listGatewayMethods", () => { expect(listGatewayMethods()).toContain("node.skills.update"); }); - it("advertises unified approval lookup and resolution", () => { + it("advertises unified approval lookup, history, and resolution", () => { expect(listGatewayMethods()).toContain("approval.get"); + expect(listGatewayMethods()).toContain("approval.history"); expect(listGatewayMethods()).toContain("approval.resolve"); }); - it("appends memory migration after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-4)).toEqual([ + it("appends new methods after model probing without shifting older method indices", () => { + expect(listGatewayMethods().slice(-5)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", "ui.command", + "approval.history", ]); }); @@ -95,7 +97,8 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-10)).toEqual([ + expect(coreMethods.slice(-12)).toEqual([ + "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", "approval.resolve", @@ -106,6 +109,7 @@ describe("listGatewayMethods", () => { "migrations.memory.plan", "migrations.memory.apply", "ui.command", + "approval.history", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); diff --git a/src/gateway/server-methods/approval.test.ts b/src/gateway/server-methods/approval.test.ts index 4b60111fe2f1..ea66652a26a8 100644 --- a/src/gateway/server-methods/approval.test.ts +++ b/src/gateway/server-methods/approval.test.ts @@ -5,7 +5,9 @@ import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + type ApprovalHistoryResult, validateApprovalGetResult, + validateApprovalHistoryResult, validateApprovalResolveResult, } from "../../../packages/gateway-protocol/src/index.js"; import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; @@ -43,7 +45,9 @@ const managersForCleanup: Array<{ }> = []; function createDatabaseOptions(): OpenClawStateDatabaseOptions { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-handler-")); + const stateDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-handler-")), + ); tempDirs.push(stateDir); return { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } }; } @@ -226,7 +230,7 @@ function createContext(controlUiBasePath?: string) { async function invoke(params: { handlers: ReturnType; - method: "approval.get" | "approval.resolve"; + method: "approval.get" | "approval.history" | "approval.resolve"; body: Record; client: GatewayRequestHandlerOptions["client"]; context?: GatewayRequestHandlerOptions["context"]; @@ -310,6 +314,59 @@ describe("unified approval handlers", () => { ); }); + it("returns mapped terminal history with attribution and a next cursor", async () => { + const databaseOptions = createDatabaseOptions(); + const managers = createManagers(databaseOptions); + const first = registerExec(managers.exec, { id: "history:first" }); + const second = registerPlugin(managers.plugin, { id: "history:second" }); + const handlers = createApprovalHandlers({ + execApprovalManager: managers.exec, + pluginApprovalManager: managers.plugin, + databaseOptions, + }); + for (const [id, kind] of [ + [first.record.id, "exec"], + [second.record.id, "plugin"], + ] as const) { + const response = await invoke({ + handlers, + method: "approval.resolve", + body: { id, kind, decision: "deny" }, + client: createClient({ deviceId: "reviewer" }), + }); + expect(response.ok).toBe(true); + } + + const firstPage = await invoke({ + handlers, + method: "approval.history", + body: { limit: 1 }, + client: createClient({ deviceId: "reviewer" }), + context: createContext("/operator/"), + }); + expect(firstPage.ok).toBe(true); + expect(validateApprovalHistoryResult(firstPage.result)).toBe(true); + const firstResult = firstPage.result as ApprovalHistoryResult; + expect(firstResult.items).toHaveLength(1); + expect(firstResult.items[0]).toMatchObject({ + status: "denied", + decision: "deny", + source: { agentId: "main", sessionKey: "agent:main:child" }, + resolver: { kind: "device", id: "reviewer" }, + }); + expect(firstResult.nextCursor).toEqual(expect.any(String)); + + const secondPage = await invoke({ + handlers, + method: "approval.history", + body: { cursor: firstResult.nextCursor, limit: 1 }, + client: createClient({ deviceId: "reviewer" }), + }); + expect(secondPage.ok).toBe(true); + expect((secondPage.result as ApprovalHistoryResult).items).toHaveLength(1); + expect((secondPage.result as ApprovalHistoryResult).nextCursor).toBeUndefined(); + }); + it("returns an exact-id, deep-linkable exec projection without execution bindings", async () => { const databaseOptions = createDatabaseOptions(); const managers = createManagers(databaseOptions); diff --git a/src/gateway/server-methods/approval.ts b/src/gateway/server-methods/approval.ts index 3b9d354ba4a3..1af61f7b19c8 100644 --- a/src/gateway/server-methods/approval.ts +++ b/src/gateway/server-methods/approval.ts @@ -5,9 +5,12 @@ import { errorShape, isWellFormedApprovalId, type ApprovalDecision, + type ApprovalHistoryParams, + type ApprovalHistoryResult, type ApprovalResolveParams, type ApprovalSnapshot, validateApprovalGetParams, + validateApprovalHistoryParams, validateApprovalResolveParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; @@ -28,6 +31,8 @@ import { import { getOperatorApprovalDetailed, getOperatorApprovalDetailedByLocator, + listTerminalOperatorApprovals, + OperatorApprovalHistoryCursorError, type OperatorApprovalRecord, type OperatorApprovalResolver, } from "../operator-approval-store.js"; @@ -79,6 +84,18 @@ function buildApprovalSnapshot( ...common, resolvedAtMs: record.resolvedAtMs, reason: record.terminalReason, + source: { + ...(record.source.agentId ? { agentId: record.source.agentId } : {}), + ...(record.source.sessionKey ? { sessionKey: record.source.sessionKey } : {}), + }, + ...(record.resolver + ? { + resolver: { + kind: record.resolver.kind, + ...(record.resolver.id ? { id: record.resolver.id } : {}), + }, + } + : {}), }; if (record.status === "allowed") { if (record.decision !== "allow-once" && record.decision !== "allow-always") { @@ -122,7 +139,7 @@ function respondApprovalNotFound(respond: RespondFn): void { function respondApprovalUnavailable(params: { context: GatewayRequestContext; respond: RespondFn; - operation: "lookup" | "resolve"; + operation: "history" | "lookup" | "resolve"; error: unknown; }): void { params.context.logGateway?.error?.( @@ -335,6 +352,50 @@ export function createApprovalHandlers( params: CreateApprovalHandlersParams, ): GatewayRequestHandlers { return { + "approval.history": ({ params: rawParams, respond, context }) => { + if (!validateApprovalHistoryParams(rawParams)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid approval.history params"), + ); + return; + } + const historyParams = rawParams as ApprovalHistoryParams; + let history: ReturnType; + try { + history = listTerminalOperatorApprovals({ + cursor: historyParams.cursor, + limit: historyParams.limit, + kind: historyParams.kind, + databaseOptions: params.databaseOptions, + }); + } catch (error) { + if (error instanceof OperatorApprovalHistoryCursorError) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid approval.history cursor"), + ); + return; + } + respondApprovalUnavailable({ context, respond, operation: "history", error }); + return; + } + const controlUiBasePath = normalizeControlUiBasePath( + context.getRuntimeConfig()?.gateway?.controlUi?.basePath, + ); + const items = history.records.flatMap((record) => { + const snapshot = buildApprovalSnapshot(record, controlUiBasePath); + return snapshot && snapshot.status !== "pending" ? [snapshot] : []; + }); + const result: ApprovalHistoryResult = { + items, + ...(history.nextCursor ? { nextCursor: history.nextCursor } : {}), + }; + respond(true, result, undefined); + }, + "approval.get": ({ params: rawParams, respond, client, context }) => { if (!validateApprovalGetParams(rawParams)) { respond( diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 10ba8e5a099f..c0af1e2467d9 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -78,6 +78,7 @@ describe("navigationIconForRoute", () => { ).toEqual({ chat: "messageSquare", activity: "activity", + approvals: "shieldCheck", workboard: "kanban", worktrees: "folder", channels: "link", @@ -130,6 +131,7 @@ describe("titleForRoute", () => { ).toEqual({ chat: "Chat", activity: "Activity", + approvals: "Approvals", workboard: "Workboard", worktrees: "Worktrees", channels: "Channels", @@ -168,6 +170,7 @@ describe("subtitleForRoute", () => { ).toEqual({ chat: "Gateway chat for quick interventions.", activity: "Browser-local tool activity summaries.", + approvals: "Recent exec, plugin, and system-agent approvals.", workboard: "Agent work queue and session handoff.", worktrees: "Isolated agent task checkouts and recovery snapshots.", channels: "Channels and settings.", @@ -206,6 +209,7 @@ describe("pathForRoute", () => { expect(pathForRoute("debug")).toBe("/debug"); expect(pathForRoute("logs")).toBe("/logs"); expect(pathForRoute("plugins")).toBe("/settings/plugins"); + expect(pathForRoute("approvals")).toBe("/settings/approvals"); }); it("prepends base path", () => { @@ -371,6 +375,7 @@ describe("SIDEBAR_NAV_ROUTES", () => { "mcp", "infrastructure", "nodes", + "approvals", "worktrees", "debug", "logs", diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index efc2bf49a7db..8c192d36f9da 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -141,7 +141,16 @@ export const SETTINGS_NAVIGATION_GROUPS = [ }, { labelKey: "nav.settingsGroupSystem", - routes: ["infrastructure", "nodes", "worktrees", "debug", "logs", "activity", "about"], + routes: [ + "infrastructure", + "nodes", + "approvals", + "worktrees", + "debug", + "logs", + "activity", + "about", + ], }, ] as const satisfies readonly SettingsNavigationGroup[]; @@ -152,6 +161,7 @@ const SETTINGS_NAVIGATION_ROUTES: readonly NavigationRouteId[] = SETTINGS_NAVIGA const NAVIGATION_ICONS: NavigationItem = { agents: "bot", activity: "activity", + approvals: "shieldCheck", workboard: "kanban", worktrees: "folder", channels: "link", @@ -242,6 +252,7 @@ export function cancelRoutePreload( const NAVIGATION_COPY: Record = { agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" }, activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" }, + approvals: { titleKey: "tabs.approvals", subtitleKey: "subtitles.approvals" }, workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" }, worktrees: { titleKey: "tabs.worktrees", subtitleKey: "subtitles.worktrees" }, channels: { titleKey: "tabs.channels", subtitleKey: "subtitles.channels" }, diff --git a/ui/src/app-route-paths.ts b/ui/src/app-route-paths.ts index 58ec550f2b07..02938a5d3541 100644 --- a/ui/src/app-route-paths.ts +++ b/ui/src/app-route-paths.ts @@ -12,6 +12,7 @@ const APP_ROUTE_DEFINITIONS = { profile: { path: "/settings/profile", aliases: ["/profile"] }, communications: { path: "/settings/communications", aliases: ["/communications"] }, appearance: { path: "/settings/appearance", aliases: ["/appearance"] }, + approvals: { path: "/settings/approvals" }, automation: { path: "/settings/automation", aliases: ["/automation"] }, mcp: { path: "/settings/mcp", aliases: ["/mcp"] }, infrastructure: { path: "/settings/infrastructure", aliases: ["/infrastructure"] }, diff --git a/ui/src/app-routes.ts b/ui/src/app-routes.ts index 509865c4a8e2..105ff4075a38 100644 --- a/ui/src/app-routes.ts +++ b/ui/src/app-routes.ts @@ -5,6 +5,7 @@ import type { ApplicationContext } from "./app/context.ts"; import { page as aboutPage } from "./pages/about/route.ts"; import { page as activityPage } from "./pages/activity/route.ts"; import { page as agentsPage } from "./pages/agents/route.ts"; +import { page as approvalsPage } from "./pages/approvals/route.ts"; import { page as channelsPage } from "./pages/channels/route.ts"; import { page as chatPage } from "./pages/chat/route.ts"; import { pages as configPages } from "./pages/config/route.ts"; @@ -45,6 +46,7 @@ const APP_ROUTE_TREE = [ newSessionPage, activityPage, agentsPage, + approvalsPage, channelsPage, connectionPage, aboutPage, diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index ac005ab28187..7e33bd1b6c67 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -452,6 +452,8 @@ class OpenClawShell extends OpenClawLightDomElement { private readonly terminalPanelElement = TERMINAL_PANEL_ELEMENT; private readonly browserPanelElement = BROWSER_PANEL_ELEMENT; @query("openclaw-command-palette") private commandPalette?: CommandPaletteElement; + @query("openclaw-exec-approval") + private approvalOverlay?: HTMLElement & { show(): void }; private commandPaletteTarget?: CommandPaletteTargetDetail; private navDrawerTrigger: HTMLElement | null = null; // Where "Back to app" / Escape leaves the settings takeover; falls back to @@ -1325,6 +1327,7 @@ class OpenClawShell extends OpenClawLightDomElement { .updateRunning=${overlaySnapshot.updateRunning} .onUpdate=${() => void context.overlays.runUpdate()} .onOpenPalette=${this.openPalette} + .onOpenApprovals=${() => this.approvalOverlay?.show()} .onToggleSidebar=${() => this.toggleNavigationSurface()} .onOpenNewSession=${(agentId: string, target?: NewSessionTarget) => { const search = newSessionSearch(agentId, target); diff --git a/ui/src/components/app-sidebar-base.ts b/ui/src/components/app-sidebar-base.ts index 5f9a1baf3945..520c0b96a601 100644 --- a/ui/src/components/app-sidebar-base.ts +++ b/ui/src/components/app-sidebar-base.ts @@ -41,6 +41,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement { @property({ attribute: false }) updateRunning = false; @property({ attribute: false }) onUpdate: () => void = () => undefined; @property({ attribute: false }) onOpenPalette?: () => void; + @property({ attribute: false }) onOpenApprovals?: () => void; @property({ attribute: false }) onToggleSidebar?: () => void; @property({ attribute: false }) onOpenNewSession?: ( agentId: string, diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 55ca12c89090..275f72ad2dd7 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -167,6 +167,7 @@ class AppSidebar extends AppSidebarSessionListElement {