mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(audit): preserve numeric decision cursors (#122619)
This commit is contained in:
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"bc2750304f9959144c04143e8f5b14ca56bae3f8ec1bdfb27d05ddbe77e01d53","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"f2752fd306b35b124b0cd32b732711be0f9831f10d13f9fcaacd4ecb39cce9e3","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"362c2fc4b9048470c4857d542f7593bb5a8a8484d4c2176f4c9e560af642db7e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"6fc88598c472783c5cd49a44a55c7ea918438eb540837772d68bbe6928e68495","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"b3dc5322d5837138fa4b857d5ca0f18cef4c1bcebb10874bef30f42c91e04465","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"dc48f51312725277ee6004c6bfe43d42d16dae0e7e0fe9371a66f4b7a859df76","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"ad3ff6b5e1b3c11bf4e03599ec3724697869e5d342f5597c835ae1dd45b7feff","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"0dff4add5bfb93c182a0c0b3f3ee471e7fbf69d9d0e1f6a342668451a4620c18","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d24c93b08546f841c3f60d03383214671a3bd8e479ff2c1aa3d7a934ef1e0d28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"15b6006cb8edd215e6df21c5c8250a25635661b6633b0a858c50ed019554aa50","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"a910e8c530f0dc757c11a62e9b6898dffb5d0274de0311728badfdae657eb1bc","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"751c9fe121607e061b2eb3e1c57fc854d650ab583873979746cf25637c551669","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"db01fbcef6813a72f89535130a49036afdf998d88de3bb29e2e5d7c62d6c95eb","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"8307497b019c13f8ce6d3159a07a4df516d6f84b7219b2ab1666ceeb3481fa61","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"80ebabbad4a9bc7b8b6eacc806fe3d94d468f3650dee8b97e4cf2883d025d08f","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"52e9b62ba55034c21c4673e2acba9c6a367d75c46e92cff698f2a6720008c17e","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"7766933d97e904bf753203045d712d8423cd33f8e018aedb39b5cdc8ac0b8a5f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"0da095103f904be5e77d287c93a739b33f192c96e0f5334571ad46245a9ecba5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"86096091c7e63bac10baec86c07cf7d9256e1ba1433a5dbb60b86df81e53ca11","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"b9c11e37dd3647431225b8fdd97d6402074a741c80f2fa3e54745bade5fcc6b7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"ee451a45f1687cede02fb776e87a630ed21fe485e1e5af1e36281161351d8278","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"307513192612557813825c129772eb642c0f4aac97ba538938dd4f6e9efd60d9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,7 +37,9 @@ function databaseOptions() {
|
||||
return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-decision-facts-") } };
|
||||
}
|
||||
|
||||
function seedExecutionContext(database: ReturnType<typeof databaseOptions>): void {
|
||||
function seedExecutionContext(
|
||||
database: ReturnType<typeof databaseOptions>,
|
||||
): ExecutionIdentityContextV1 {
|
||||
let envelope: ExecutionIdentityAdmissionEnvelope | undefined;
|
||||
const clear = configureExecutionIdentityAdmissionSink((work) => {
|
||||
if (work.kind === "capture") {
|
||||
@@ -78,6 +80,7 @@ function seedExecutionContext(database: ReturnType<typeof databaseOptions>): 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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-8
@@ -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;
|
||||
|
||||
@@ -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<number>().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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -300,6 +300,31 @@ async function runProof(options: ProducerOptions): Promise<string> {
|
||||
"pre-restart decision inspection",
|
||||
);
|
||||
const receipt = requireDeniedApproval(before);
|
||||
const firstPage = parseJson<AuditRunInspectResult>(
|
||||
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<AuditRunInspectResult>(
|
||||
await gateway.runCli(["audit", "--run", runId, "--explain", "--cursor", "001", "--json"]),
|
||||
"legacy numeric decision continuation",
|
||||
);
|
||||
requireDeniedApproval(legacyResume);
|
||||
const opaqueResume = parseJson<AuditRunInspectResult>(
|
||||
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<string> {
|
||||
firstAnswerPreserved: true,
|
||||
agentCompletionObserved: true,
|
||||
genericDuplicateAbsent: true,
|
||||
numericDecisionContinuation: true,
|
||||
opaqueDecisionContinuation: true,
|
||||
byteEquivalentAfterRestart: true,
|
||||
redaction: { command: true, toolCall: true },
|
||||
resultSha256: sha256(serialized),
|
||||
|
||||
Reference in New Issue
Block a user