diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index bbb5a1e4fe54..7ddbfed0eea7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"3197bbee115b315bb7ffaec2f495abba5bc9981a90751760da6c430be3ee5445","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"db7aab74b421a7ed47dac3fd71ca80190d2df3102a1a890b0d72323ed155d11d","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index f33b64194546..0d5772c872b3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"bc2750304f9959144c04143e8f5b14ca56bae3f8ec1bdfb27d05ddbe77e01d53","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"f2752fd306b35b124b0cd32b732711be0f9831f10d13f9fcaacd4ecb39cce9e3","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 64f43a589af0..11be50f97c47 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"362c2fc4b9048470c4857d542f7593bb5a8a8484d4c2176f4c9e560af642db7e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"6fc88598c472783c5cd49a44a55c7ea918438eb540837772d68bbe6928e68495","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index c6e101d3c8ef..58a65efdfcb3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"bf8fa1e5fbb43f0a5fc3b891b93a848684414d78cb88c8f75b73491e4e46b0c7","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"c9cc79970dbfef3cf73c2abaa966daa4206b25cc87d43562a32eb44934d6bb0c","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index e0e3b05b70d3..5411d8f1feaf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"b3dc5322d5837138fa4b857d5ca0f18cef4c1bcebb10874bef30f42c91e04465","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"dc48f51312725277ee6004c6bfe43d42d16dae0e7e0fe9371a66f4b7a859df76","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index da36845c984a..cdc70252ada3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"ad3ff6b5e1b3c11bf4e03599ec3724697869e5d342f5597c835ae1dd45b7feff","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"0dff4add5bfb93c182a0c0b3f3ee471e7fbf69d9d0e1f6a342668451a4620c18","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 9243511fe10e..ffe887e043f0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"d2bfeec50ac2ee0abbf987a58b004820060f1d3d5634834e781d125da7e7a930","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"d2a7bb51e14d60bcc1f623f9fabf8baa3d4d2b253f8a643b331e10e20c0ca9ac","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index d30de5ad0c39..8179da6eebdd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"d24c93b08546f841c3f60d03383214671a3bd8e479ff2c1aa3d7a934ef1e0d28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"15b6006cb8edd215e6df21c5c8250a25635661b6633b0a858c50ed019554aa50","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index c72db5a25f8c..cc70fb436463 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"a910e8c530f0dc757c11a62e9b6898dffb5d0274de0311728badfdae657eb1bc","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"751c9fe121607e061b2eb3e1c57fc854d650ab583873979746cf25637c551669","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 60f036854df5..671d009f642a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"4ba70d6eaf72efab607d4b2f9783df5bafdf21ab43a1f4a2947a48a829ed0337","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"5824cd4a9e5b2242177cfa79040d2bba2eb22b90cb1d2cf0d257d2088b47a6f3","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 58d2af41d2a0..6020be498209 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"db01fbcef6813a72f89535130a49036afdf998d88de3bb29e2e5d7c62d6c95eb","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"8307497b019c13f8ce6d3159a07a4df516d6f84b7219b2ab1666ceeb3481fa61","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 4a48fd4bae0f..a698cdbe0cfb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"80ebabbad4a9bc7b8b6eacc806fe3d94d468f3650dee8b97e4cf2883d025d08f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"52e9b62ba55034c21c4673e2acba9c6a367d75c46e92cff698f2a6720008c17e","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index a2cddc7a8164..e23be8087c67 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"7766933d97e904bf753203045d712d8423cd33f8e018aedb39b5cdc8ac0b8a5f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"0da095103f904be5e77d287c93a739b33f192c96e0f5334571ad46245a9ecba5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 45a9548396b7..76bcfd003c9c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"17b9e593a0f1aebd5e985c34cbfc6a6df8963f8e73b25cfb9ca756a0e0918b77","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"244eeb2fb3601d42e421c46f46e21432c954ddfdacc928fd6a9baa6a9ef6deba","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index d29e6c73f8e9..37801fe08deb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"86096091c7e63bac10baec86c07cf7d9256e1ba1433a5dbb60b86df81e53ca11","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"b9c11e37dd3647431225b8fdd97d6402074a741c80f2fa3e54745bade5fcc6b7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index e51795802cb5..9602bafef2ec 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"ee451a45f1687cede02fb776e87a630ed21fe485e1e5af1e36281161351d8278","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"307513192612557813825c129772eb642c0f4aac97ba538938dd4f6e9efd60d9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/qa/scenarios/runtime/agent-run-decision-receipt.yaml b/qa/scenarios/runtime/agent-run-decision-receipt.yaml index 924ea3f1681a..00a6b7f0a158 100644 --- a/qa/scenarios/runtime/agent-run-decision-receipt.yaml +++ b/qa/scenarios/runtime/agent-run-decision-receipt.yaml @@ -13,6 +13,7 @@ scenario: - A real trusted agent exec request records an exact execution binding, is denied by an approval-capable Gateway client, and a conflicting later allow cannot replace the first answer. - Text and JSON audit output report the denial reason, enforced state, authoritative durable owner, bounded policy references, and remediation. - Receipt output omits raw command, tool-call, and reviewer-device details and creates no generic duplicate. + - A positive numeric compatibility cursor resumes the bounded decision receipt page and returns an opaque successor accepted by the same Gateway handler. - A replacement Gateway process returns byte-equivalent decision inspection JSON. docsRefs: - docs/gateway/audit.md diff --git a/src/audit/audit-cursor.ts b/src/audit/audit-cursor.ts new file mode 100644 index 000000000000..2d13cd75b4e8 --- /dev/null +++ b/src/audit/audit-cursor.ts @@ -0,0 +1,13 @@ +import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; + +/** Parse the digit-only positive cursor grammar shared by audit CLI and Gateway paging. */ +export function parsePositiveAuditCursor(cursor: string | undefined): number | undefined | null { + if (cursor === undefined) { + return undefined; + } + const trimmed = cursor.trim(); + if (!/^\d+$/.test(trimmed)) { + return null; + } + return parseStrictPositiveInteger(trimmed) ?? null; +} diff --git a/src/audit/execution-decision-facts.test.ts b/src/audit/execution-decision-facts.test.ts index fa6e3ec0fbe9..21f16a89dd8b 100644 --- a/src/audit/execution-decision-facts.test.ts +++ b/src/audit/execution-decision-facts.test.ts @@ -37,7 +37,9 @@ function databaseOptions() { return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-decision-facts-") } }; } -function seedExecutionContext(database: ReturnType): void { +function seedExecutionContext( + database: ReturnType, +): ExecutionIdentityContextV1 { let envelope: ExecutionIdentityAdmissionEnvelope | undefined; const clear = configureExecutionIdentityAdmissionSink((work) => { if (work.kind === "capture") { @@ -78,6 +80,7 @@ function seedExecutionContext(database: ReturnType): voi ) { throw new Error(`unexpected execution context: ${JSON.stringify(stored)}`); } + return stored; } function receipt(id: string, occurredAt = 100): DecisionReceiptV1 { @@ -193,7 +196,7 @@ describe("execution decision facts", () => { it("pages equal-time facts by a bounded row key", () => { const database = databaseOptions(); - seedExecutionContext(database); + const context = seedExecutionContext(database); for (const id of ["same-time-a", "same-time-b", "same-time-c"]) { recordExecutionDecisionFact(receipt(id, 100), { ...database, now: 100 }); } @@ -215,6 +218,31 @@ describe("execution decision facts", () => { database, }).receipts.map((item) => item.receiptId), ).toEqual(["same-time-b", "same-time-c"]); + + for (const decisionCursor of ["1", "001"]) { + const legacyPage = presentExecutionDecisionReceipts({ + context, + decisionCursor, + decisionLimit: 1, + options: { ...database, now: 100 }, + }); + expect(legacyPage.decisions.map((item) => item.receiptId)).toEqual(["same-time-a"]); + expect(legacyPage.nextDecisionCursor).toMatch(/^g:/); + } + const legacyPage = presentExecutionDecisionReceipts({ + context, + decisionCursor: "1", + decisionLimit: 1, + options: { ...database, now: 100 }, + }); + expect( + presentExecutionDecisionReceipts({ + context, + decisionCursor: legacyPage.nextDecisionCursor, + decisionLimit: 2, + options: { ...database, now: 100 }, + }).decisions.map((item) => item.receiptId), + ).toEqual(["same-time-b", "same-time-c"]); }); it("bounds aggregated missing evidence at the result protocol boundary", () => { diff --git a/src/audit/execution-decision-facts.ts b/src/audit/execution-decision-facts.ts index 13515e9a1492..713d6f08655a 100644 --- a/src/audit/execution-decision-facts.ts +++ b/src/audit/execution-decision-facts.ts @@ -326,6 +326,7 @@ function retainedDecisionFactMetadata(params: { contextId: string; now: number; after?: ExecutionDecisionFactCursor; + offset?: number; limit: number; }): ExecutionDecisionMetadataRow[] { const boundary = params.after @@ -377,6 +378,7 @@ function retainedDecisionFactMetadata(params: { ]) .orderBy("occurred_at", "asc") .orderBy("receipt_id", "asc") + .$if(params.offset !== undefined, (query) => query.offset(params.offset!)) .limit(params.limit), ).rows; } @@ -522,6 +524,7 @@ export function hasExecutionDecisionFactsForRun(params: { export function pageExecutionDecisionFactsForContext(params: { context: ExecutionDecisionContext; after?: ExecutionDecisionFactCursor; + offset?: number; limit: number; now?: number; database?: OpenClawStateDatabaseOptions; @@ -536,6 +539,7 @@ export function pageExecutionDecisionFactsForContext(params: { contextId: params.context.contextId, now: params.now ?? Date.now(), after: params.after, + offset: params.offset, limit: params.limit + 1, }); const pageMetadata = metadataRows.slice(0, params.limit); diff --git a/src/audit/execution-decision-receipts.ts b/src/audit/execution-decision-receipts.ts index 4e1630f109bf..9828c631addb 100644 --- a/src/audit/execution-decision-receipts.ts +++ b/src/audit/execution-decision-receipts.ts @@ -9,6 +9,7 @@ import { summarizeOperatorApprovalReceiptsForRun, } from "../gateway/operator-approval-store.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { parsePositiveAuditCursor } from "./audit-cursor.js"; import { pageExecutionDecisionFactsForContext, summarizeExecutionDecisionFactsForContext, @@ -18,10 +19,14 @@ type ExecutionDecisionReadOptions = OpenClawStateDatabaseOptions & { now?: numbe const MAX_AGGREGATE_MISSING_EVIDENCE = 16; const MISSING_EVIDENCE_TRUNCATED = "decision.missing_evidence_truncated"; -type DecisionCursor = { - stage: "approval" | "generic"; - after?: { occurredAt: number; rowId: number }; -}; +type DecisionCursor = + | { + stage: "approval" | "generic"; + after?: { occurredAt: number; rowId: number }; + } + | { + offset: number; + }; export class ExecutionDecisionCursorError extends Error { constructor(message = "invalid execution decision cursor") { @@ -34,6 +39,10 @@ function parseDecisionCursor(value: string | undefined): DecisionCursor | undefi if (value === undefined) { return undefined; } + const offset = parsePositiveAuditCursor(value); + if (offset !== null && offset !== undefined) { + return { offset }; + } const match = /^([ag]):(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(value); if (!match) { return null; @@ -129,6 +138,10 @@ export function presentExecutionDecisionReceipts(params: { } const limit = params.decisionLimit ?? 50; const now = params.options.now ?? Date.now(); + // Numeric cursors are the shipped aggregate offset. Resolve its owner span + // once, then let the canonical bounded owner pagers emit opaque successors. + const opaqueCursor = cursor && "stage" in cursor ? cursor : undefined; + const legacyOffset = cursor && "offset" in cursor ? cursor.offset - 1 : undefined; const approvalSummary = summarizeOperatorApprovalReceiptsForRun({ context: { contextId: params.context.contextId, @@ -137,6 +150,7 @@ export function presentExecutionDecisionReceipts(params: { }, nowMs: now, databaseOptions: params.options, + exactCount: legacyOffset !== undefined, }); const genericSummary = summarizeExecutionDecisionFactsForContext({ context: params.context, @@ -146,6 +160,10 @@ export function presentExecutionDecisionReceipts(params: { const decisions: DecisionReceiptV1[] = []; let remainingLimit = limit; let nextDecisionCursor: string | undefined; + const approvalOffset = + legacyOffset !== undefined && legacyOffset < approvalSummary.count ? legacyOffset : undefined; + const genericOffset = + legacyOffset === undefined ? undefined : Math.max(0, legacyOffset - approvalSummary.count); if (cursor === undefined && remainingLimit > 0) { decisions.push(admissionDecision(params.context)); @@ -154,7 +172,11 @@ export function presentExecutionDecisionReceipts(params: { nextDecisionCursor = formatDecisionCursor("approval"); } } - if (remainingLimit > 0 && cursor?.stage !== "generic") { + if ( + remainingLimit > 0 && + opaqueCursor?.stage !== "generic" && + (legacyOffset === undefined || approvalOffset !== undefined) + ) { let page; try { page = pageOperatorApprovalReceiptsForRun({ @@ -163,7 +185,8 @@ export function presentExecutionDecisionReceipts(params: { executionId: params.context.executionId, runId: params.context.runId, }, - after: cursor?.stage === "approval" ? cursor.after : undefined, + after: opaqueCursor?.stage === "approval" ? opaqueCursor.after : undefined, + offset: approvalOffset, limit: remainingLimit, nowMs: now, databaseOptions: params.options, @@ -189,7 +212,8 @@ export function presentExecutionDecisionReceipts(params: { try { page = pageExecutionDecisionFactsForContext({ context: params.context, - after: cursor?.stage === "generic" ? cursor.after : undefined, + after: opaqueCursor?.stage === "generic" ? opaqueCursor.after : undefined, + offset: genericOffset, limit: remainingLimit, now, database: params.options, diff --git a/src/audit/execution-identity-context.test.ts b/src/audit/execution-identity-context.test.ts index 74021cff4045..1ea340a5cbae 100644 --- a/src/audit/execution-identity-context.test.ts +++ b/src/audit/execution-identity-context.test.ts @@ -1005,6 +1005,12 @@ describe("execution identity context storage", () => { ).toMatchObject({ decisions: [{ decision: { reasonCode: "operator_approval_denied_by_reviewer" } }], }); + expect( + inspectExecutionIdentityRun( + { runId: "run-denied-receipt", decisionCursor: "1", decisionLimit: 1 }, + { ...database, now: 300 }, + ).decisions, + ).toMatchObject([{ decision: { reasonCode: "operator_approval_denied_by_reviewer" } }]); }); it("keeps a corrupt approval unknown before its decision page is returned", () => { diff --git a/src/commands/audit.test.ts b/src/commands/audit.test.ts index 333fac160376..ad602ad913c6 100644 --- a/src/commands/audit.test.ts +++ b/src/commands/audit.test.ts @@ -565,6 +565,50 @@ describe("audit run explanation", () => { }); }); + it("routes the shared explain cursor by selector and grammar", async () => { + callGateway.mockResolvedValue({ + schemaVersion: 1, + run: { runId: "run-1", executionId: "execution-1", status: "known" }, + identity: { + state: "unknown", + reasonCode: "execution_not_found", + missingEvidence: ["identity.context"], + remediation: [], + }, + decisions: [], + coverage: { state: "unknown", missingEvidence: ["identity.context"] }, + }); + + for (const [options, params] of [ + [ + { explain: true, runId: "run-1", cursor: "a:2000:42" }, + { runId: "run-1", executionLimit: 50, decisionCursor: "a:2000:42", decisionLimit: 50 }, + ], + [ + { explain: true, executionId: "execution-1", cursor: "1" }, + { executionId: "execution-1", decisionCursor: "1", decisionLimit: 50 }, + ], + [ + { explain: true, runId: "run-1", cursor: "001" }, + { + runId: "run-1", + executionLimit: 50, + executionCursor: "001", + decisionCursor: "001", + decisionLimit: 50, + }, + ], + [ + { explain: true, executionId: "execution-1", cursor: "g:2000:42" }, + { executionId: "execution-1", decisionCursor: "g:2000:42", decisionLimit: 50 }, + ], + ] as const) { + callGateway.mockClear(); + await auditListCommand(options, runtime); + expect(callGateway).toHaveBeenCalledWith({ method: "audit.run.inspect", params }); + } + }); + it("renders expired identity as unsupported without context fields or decisions", async () => { callGateway.mockResolvedValue({ schemaVersion: 1, diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 1610dec24e3c..5674c048081b 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -16,6 +16,7 @@ import type { PrincipalRefV1, } from "../../packages/gateway-protocol/src/index.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { parsePositiveAuditCursor } from "../audit/audit-cursor.js"; import { parseAbsoluteTimeMs } from "../cron/parse.js"; import { callGateway } from "../gateway/call.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -472,17 +473,24 @@ export async function auditListCommand( ); } const decisionLimit = parseAuditDecisionLimit(options.limit); - const result = await queryAuditRunInspection({ - ...(executionId - ? { executionId } + const cursor = options.cursor; + const numericCursor = parsePositiveAuditCursor(cursor); + const runExecutionCursor = + numericCursor !== undefined && numericCursor !== null ? cursor : undefined; + const decisionPage = { + decisionLimit, + ...(cursor ? { decisionCursor: cursor } : {}), + }; + const result = await queryAuditRunInspection( + executionId + ? { executionId, ...decisionPage } : { runId: runId!, executionLimit: Math.min(decisionLimit, MAX_AUDIT_EXECUTION_LIMIT), - ...(options.cursor ? { executionCursor: options.cursor } : {}), - }), - decisionLimit, - ...(options.cursor ? { decisionCursor: options.cursor } : {}), - }); + ...(runExecutionCursor ? { executionCursor: runExecutionCursor } : {}), + ...decisionPage, + }, + ); if (options.json) { writeRuntimeJson(runtime, result); return; diff --git a/src/gateway/operator-approval-store.ts b/src/gateway/operator-approval-store.ts index aba79750f669..62d452038ac0 100644 --- a/src/gateway/operator-approval-store.ts +++ b/src/gateway/operator-approval-store.ts @@ -795,6 +795,7 @@ function terminalApprovalReceiptMetadataRows(params: { runId: string; nowMs: number; after?: OperatorApprovalReceiptCursor; + offset?: number; limit: number; }): OperatorApprovalReceiptMetadataRow[] { const boundary = params.after @@ -825,6 +826,7 @@ function terminalApprovalReceiptMetadataRows(params: { ) .orderBy("operator_approvals.resolved_at_ms", "asc") .orderBy("operator_approvals.approval_id", "asc") + .$if(params.offset !== undefined, (query) => query.offset(params.offset!)) .limit(params.limit); const metadata = (query: typeof ordered) => query @@ -963,6 +965,7 @@ export function summarizeOperatorApprovalReceiptsForRun(params: { context: OperatorApprovalReceiptContext; nowMs?: number; databaseOptions?: OpenClawStateDatabaseOptions; + exactCount?: boolean; }): { count: number; coverageState?: "enforced" | "unknown"; @@ -981,13 +984,21 @@ export function summarizeOperatorApprovalReceiptsForRun(params: { nowMs: params.nowMs ?? Date.now(), limit: OPERATOR_APPROVAL_RECEIPT_SUMMARY_MAX_ROWS + 1, }); - const count = metadataRows.length; - if (count === 0) { + const boundedCount = metadataRows.length; + const count = params.exactCount + ? (executeSqliteQueryTakeFirstSync( + db, + terminalApprovalsForRunQuery(stateDb, params.context.runId, params.nowMs ?? Date.now()) + .clearSelect() + .select((eb) => eb.fn.countAll().as("count")), + )?.count ?? 0) + : boundedCount; + if (boundedCount === 0) { return { count: 0, missingEvidence: [] }; } // Whole-set coverage stays conservative without decoding an unbounded // collection on the Gateway event loop. - if (count > OPERATOR_APPROVAL_RECEIPT_SUMMARY_MAX_ROWS) { + if (boundedCount > OPERATOR_APPROVAL_RECEIPT_SUMMARY_MAX_ROWS) { return { count, coverageState: "unknown" as const, @@ -1039,6 +1050,7 @@ export function summarizeOperatorApprovalReceiptsForRun(params: { export function pageOperatorApprovalReceiptsForRun(params: { context: OperatorApprovalReceiptContext; after?: OperatorApprovalReceiptCursor; + offset?: number; limit: number; nowMs?: number; databaseOptions?: OpenClawStateDatabaseOptions; @@ -1055,6 +1067,7 @@ export function pageOperatorApprovalReceiptsForRun(params: { runId: params.context.runId, nowMs: params.nowMs ?? Date.now(), after: params.after, + offset: params.offset, limit: params.limit + 1, }); const pageMetadata = metadataRows.slice(0, params.limit); diff --git a/src/gateway/server-methods/audit.test.ts b/src/gateway/server-methods/audit.test.ts index 0b22ecefe454..5b946282b315 100644 --- a/src/gateway/server-methods/audit.test.ts +++ b/src/gateway/server-methods/audit.test.ts @@ -290,6 +290,45 @@ describe("audit gateway methods", () => { executionId: "execution-1", decisionLimit: 20, }); + + await runAuditHandler("audit.run.inspect", { + runId: "run-1", + executionCursor: "1", + decisionCursor: "1", + decisionLimit: 25, + }); + expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({ + runId: "run-1", + executionOffset: 1, + executionLimit: 50, + decisionCursor: "1", + decisionLimit: 25, + }); + + await runAuditHandler("audit.run.inspect", { + runId: "run-1", + executionCursor: "001", + decisionCursor: "001", + decisionLimit: 25, + }); + expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({ + runId: "run-1", + executionOffset: 1, + executionLimit: 50, + decisionCursor: "001", + decisionLimit: 25, + }); + + await runAuditHandler("audit.run.inspect", { + executionId: "execution-1", + decisionCursor: "1", + decisionLimit: 20, + }); + expect(inspectExecutionIdentityRun).toHaveBeenLastCalledWith({ + executionId: "execution-1", + decisionCursor: "1", + decisionLimit: 20, + }); }); it("rejects malformed run inspection before storage access", async () => { @@ -299,6 +338,11 @@ describe("audit gateway methods", () => { expect( await runAuditHandler("audit.run.inspect", { runId: "run-1", decisionCursor: "0" }), ).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + for (const decisionCursor of ["-1", "1.5", "1a", "a:1:2x", "9007199254740992"]) { + expect( + await runAuditHandler("audit.run.inspect", { runId: "run-1", decisionCursor }), + ).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + } expect( await runAuditHandler("audit.run.inspect", { runId: "run-1", diff --git a/src/gateway/server-methods/audit.ts b/src/gateway/server-methods/audit.ts index 8079cbc27d3b..2d53a3de6b0e 100644 --- a/src/gateway/server-methods/audit.ts +++ b/src/gateway/server-methods/audit.ts @@ -9,6 +9,7 @@ import { validateAuditListParams, validateAuditRunInspectParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { parsePositiveAuditCursor } from "../../audit/audit-cursor.js"; import { listAuditEvents } from "../../audit/audit-event-store.js"; import type { AgentRunAuditEventRecord, @@ -26,18 +27,6 @@ import { assertValidParams } from "./validation.js"; const DEFAULT_AUDIT_LIST_LIMIT = 100; const MAX_AUDIT_LIST_LIMIT = 500; -function parsePositiveCursor(cursor: string | undefined): number | undefined | null { - if (cursor === undefined) { - return undefined; - } - const trimmed = cursor.trim(); - if (!/^\d+$/.test(trimmed)) { - return null; - } - const parsed = Number(trimmed); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; -} - /** Preserve the shipped audit.list result shape for run/tool-only clients. */ function mapLegacyAuditEvent( event: AgentRunAuditEventRecord | ToolActionAuditEventRecord, @@ -74,7 +63,7 @@ function invalidRangeOrCursor(params: { cursor?: string; after?: number; before? cursor?: number; invalid: boolean; } { - const cursor = parsePositiveCursor(params.cursor); + const cursor = parsePositiveAuditCursor(params.cursor); return { ...(cursor !== undefined && cursor !== null ? { cursor } : {}), invalid: @@ -171,9 +160,9 @@ export const auditHandlers: GatewayRequestHandlers = { typeof params.runId !== "string" || (params.executionCursor === decisionCursor && decisionCursor !== undefined && - isExecutionDecisionCursor(decisionCursor)) + (decisionCursor.startsWith("a:") || decisionCursor.startsWith("g:"))) ? undefined - : parsePositiveCursor(params.executionCursor); + : parsePositiveAuditCursor(params.executionCursor); if ( (decisionCursor !== undefined && !isExecutionDecisionCursor(decisionCursor)) || executionOffset === null diff --git a/test/e2e/qa-lab/runtime/agent-run-decision-receipt.ts b/test/e2e/qa-lab/runtime/agent-run-decision-receipt.ts index c6dbe7a6befb..c3307bcf38bb 100644 --- a/test/e2e/qa-lab/runtime/agent-run-decision-receipt.ts +++ b/test/e2e/qa-lab/runtime/agent-run-decision-receipt.ts @@ -300,6 +300,31 @@ async function runProof(options: ProducerOptions): Promise { "pre-restart decision inspection", ); const receipt = requireDeniedApproval(before); + const firstPage = parseJson( + await gateway.runCli(["audit", "--run", runId, "--explain", "--limit", "1", "--json"]), + "first decision page", + ); + if (firstPage.nextDecisionCursor?.startsWith("a:") !== true) { + throw new Error("first decision page omitted its opaque approval cursor"); + } + const legacyResume = parseJson( + await gateway.runCli(["audit", "--run", runId, "--explain", "--cursor", "001", "--json"]), + "legacy numeric decision continuation", + ); + requireDeniedApproval(legacyResume); + const opaqueResume = parseJson( + await gateway.runCli([ + "audit", + "--run", + runId, + "--explain", + "--cursor", + firstPage.nextDecisionCursor, + "--json", + ]), + "opaque decision continuation", + ); + requireDeniedApproval(opaqueResume); const serialized = JSON.stringify(before); const toolCallRef = readApprovalToolCallRef(gateway, approvalId); if (serialized.includes(commandSentinel) || serialized.includes(toolCallRef)) { @@ -336,6 +361,8 @@ async function runProof(options: ProducerOptions): Promise { firstAnswerPreserved: true, agentCompletionObserved: true, genericDuplicateAbsent: true, + numericDecisionContinuation: true, + opaqueDecisionContinuation: true, byteEquivalentAfterRestart: true, redaction: { command: true, toolCall: true }, resultSha256: sha256(serialized),