refactor(gateway): offload approval grant administration (#162549)

* refactor(state): register worker operations once per domain

Infer shared-state worker command contracts from lazy per-domain handler tables. Migrate Web Push, APNs, worktree registry operations, and fleet registry while preserving the existing broker and transaction owners.

* refactor(gateway): run operator approval grants in workers

* test(agents): retain cron grant worker test routing

* fix(gateway): break approval worker type-import cycles

* refactor(gateway): keep cron grants at the launch boundary

* fix(test): prepare Discord capture workers before test admission

* fix(deps): drop stale package-manager lock metadata

Apply the lockfile-only fix from main 4b32b40150. The CI build prunes the duplicate @pnpm/exe environment entry and leaves the checkout dirty, causing prebuilt real-Gateway UI setup to refuse it. Application dependency bytes and pinned versions are unchanged.
This commit is contained in:
Peter Steinberger 2026-10-01 05:39:52 -07:00 • committed by GitHub
parent 72f5840c11
commit 1b33b99c40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 578 additions and 428 deletions

View file

@ -2341,7 +2341,6 @@ src/infra/exec-approval-policy-snapshot.ts 2
src/infra/exec-approval-reply.ts 2
src/infra/exec-approvals-allowlist.ts 7
src/infra/exec-approvals-config.ts 4
src/infra/exec-approvals-store.ts 1
src/infra/exec-argv-analysis.ts 1
src/infra/exec-host.ts 3
src/infra/fetch-headers.ts 3

View file

@ -53,6 +53,9 @@ export function shouldPrepareVitestCoreWorkers(
...(includesProject(contracts)
? ["src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts"]
: []),
...(includesProject("test/vitest/vitest.e2e.config.ts")
? ["test/e2e/gateway-transcripts-discord-capture.e2e.test.ts"]
: []),
];
const codeModeWorker = "src/agents/code-mode.import-boundary.test.ts";
return (

View file

@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCronJobConfigRevision } from "../cron/config-revision.js";
import {
deleteCronJobRowInDatabase,
@ -13,7 +13,9 @@ import {
} from "../cron/store/row-codec.js";
import type { CronStoredJob } from "../cron/types.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { assertSqliteSchemaContains } from "../infra/sqlite-schema-contract.js";
import * as workerAdmission from "../infra/sqlite-worker-operation-admission.js";
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
@ -26,17 +28,18 @@ import {
import { OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY } from "../state/openclaw-state-schema-compatibility.js";
import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.js";
import { createTestGatewayScheduler } from "../test-utils/gateway-scheduler-clock.js";
import { observeMainThreadSql } from "../test-utils/main-thread-sql-spies.test-support.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import {
buildCronExecOperationBinding,
consumeCronStandingGrant,
listCronStandingGrants,
mintCronStandingGrantLocked,
parseCronExecOperationBinding,
revokeCronStandingGrant,
} from "./operator-approval-standing-grants.js";
import {
closeOrphanedOperatorApprovals,
listCronStandingGrants,
revokeCronStandingGrant,
insertOperatorApproval,
resolveOperatorApproval,
} from "./operator-approval-store.js";
@ -81,6 +84,7 @@ function createDatabaseOptions(): OpenClawStateDatabaseOptions {
}
afterEach(async () => {
vi.restoreAllMocks();
await closeOpenClawStateDatabaseAsync();
closeOpenClawStateDatabaseForTest();
for (const dir of tempDirs.splice(0)) {
@ -833,9 +837,62 @@ describe("standing grant operator surfaces", () => {
return { databaseOptions, revision };
}
it("keeps grant listing and revocation off the calling thread", async () => {
const { databaseOptions } = await seedListedGrant();
requireNodeSqlite();
const sql = observeMainThreadSql();
try {
sql.calibrate();
const [grant] = await listCronStandingGrants({ databaseOptions });
expect(grant).toMatchObject({ cronJobId: "job-1", revokedAtMs: null, useCount: 0 });
expect(
await revokeCronStandingGrant({
grantId: grant!.grantId,
revokedBy: "reviewer",
databaseOptions,
}),
).toMatchObject({ outcome: "revoked" });
expect(await listCronStandingGrants({ databaseOptions })).toMatchObject([
{ grantId: grant!.grantId, revokedBy: "reviewer" },
]);
sql.expectIdle();
} finally {
sql.restore();
}
});
it("rolls back revocation when authority is revoked at worker commit", async () => {
const { databaseOptions } = await seedListedGrant();
const [before] = await listCronStandingGrants({ databaseOptions });
let current = true;
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
vi.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission").mockImplementation(
(admit, attachment) =>
createAdmission((request, grant) => {
if (request.stage === "commit") {
current = false;
}
return admit(request, grant);
}, attachment),
);
const pending = revokeCronStandingGrant({
grantId: before!.grantId,
revokedBy: "reviewer",
databaseOptions,
assertCurrent() {
if (!current) {
throw new Error("synthetic grant authority revoked");
}
},
});
await expect(pending).rejects.toThrow("synthetic grant authority revoked");
vi.restoreAllMocks();
expect(await listCronStandingGrants({ databaseOptions })).toEqual([before]);
});
it("lists grants with the owning job name and parseable operation", async () => {
const { databaseOptions } = await seedListedGrant();
const grants = listCronStandingGrants({ databaseOptions });
const grants = await listCronStandingGrants({ databaseOptions });
expect(grants).toHaveLength(1);
const grant = grants[0]!;
expect(grant.cronJobId).toBe("job-1");
@ -847,16 +904,16 @@ describe("standing grant operator surfaces", () => {
expect(operation?.command).toBe("echo standing");
});
it("returns an empty list before any grant created the table", () => {
it("returns an empty list before any grant created the table", async () => {
const databaseOptions = createDatabaseOptions();
seedCronJob(databaseOptions);
expect(listCronStandingGrants({ databaseOptions })).toEqual([]);
expect(await listCronStandingGrants({ databaseOptions })).toEqual([]);
});
it("revokes once, reports already-revoked after, and fails closed at consume", async () => {
const { databaseOptions, revision } = await seedListedGrant();
const grantId = listCronStandingGrants({ databaseOptions })[0]!.grantId;
const revoked = revokeCronStandingGrant({
const grantId = (await listCronStandingGrants({ databaseOptions }))[0]!.grantId;
const revoked = await revokeCronStandingGrant({
grantId,
revokedBy: "operator-cli",
nowMs: NOW_MS + 2_000,
@ -874,9 +931,10 @@ describe("standing grant operator surfaces", () => {
}).outcome,
).toBe("revoked");
expect(
revokeCronStandingGrant({ grantId, revokedBy: "someone-else", databaseOptions }).outcome,
(await revokeCronStandingGrant({ grantId, revokedBy: "someone-else", databaseOptions }))
.outcome,
).toBe("already-revoked");
const listed = listCronStandingGrants({ databaseOptions })[0]!;
const listed = (await listCronStandingGrants({ databaseOptions }))[0]!;
expect(listed.revokedAtMs).toBe(NOW_MS + 2_000);
expect(listed.revokedBy).toBe("operator-cli");
});
@ -884,15 +942,17 @@ describe("standing grant operator surfaces", () => {
it("reports not-found for unknown grants and before the table exists", async () => {
const databaseOptions = createDatabaseOptions();
expect(
revokeCronStandingGrant({ grantId: "missing", revokedBy: "x", databaseOptions }).outcome,
(await revokeCronStandingGrant({ grantId: "missing", revokedBy: "x", databaseOptions }))
.outcome,
).toBe("not-found");
await seedListedGrant();
expect(
revokeCronStandingGrant({ grantId: "missing", revokedBy: "x", databaseOptions }).outcome,
(await revokeCronStandingGrant({ grantId: "missing", revokedBy: "x", databaseOptions }))
.outcome,
).toBe("not-found");
});
it("rebuilds the unshipped mandatory-expiry table shape on first use", () => {
it("rebuilds the unshipped mandatory-expiry table shape on first use", async () => {
const databaseOptions = createDatabaseOptions();
const revision = seedCronJob(databaseOptions);
const database = openOpenClawStateDatabase(databaseOptions);
@ -932,7 +992,7 @@ describe("standing grant operator surfaces", () => {
},
}),
).toMatchObject({ outcome: "resolved" });
const grants = listCronStandingGrants({ databaseOptions });
const grants = await listCronStandingGrants({ databaseOptions });
expect(grants).toHaveLength(1);
expect(grants[0]!.expiresAtMs).toBeNull();
});

View file

@ -25,7 +25,13 @@ import {
type OpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import type { CronStandingGrantMintSpec } from "./operator-approval-standing-grants.types.js";
import type {
CronStandingGrantMintSpec,
CronStandingGrantRecord,
ConsumeCronStandingGrantResult,
CronStandingGrantListing,
RevokeCronStandingGrantResult,
} from "./operator-approval-standing-grants.types.js";
const STANDING_GRANT_TABLE = "operator_approval_standing_grants";
const STANDING_GRANT_GENERATION_TABLE = "operator_approval_standing_grant_generations";
@ -68,16 +74,6 @@ type StandingGrantDatabase = Pick<
| "cron_jobs"
>;
type CronStandingGrantRecord = CronStandingGrantMintSpec & {
grantId: string;
mintedByApprovalId: string;
createdAtMs: number;
/** NULL means the grant lives until revoked or superseded. */
expiresAtMs: number | null;
lastUsedAtMs: number | null;
useCount: number;
};
function projectCronStandingGrant(
row: Selectable<StandingGrantDatabase[typeof STANDING_GRANT_TABLE]>,
): CronStandingGrantRecord {
@ -95,19 +91,6 @@ function projectCronStandingGrant(
};
}
export type ConsumeCronStandingGrantResult =
| { outcome: "consumed"; grant: CronStandingGrantRecord }
| {
outcome:
| "no-grant"
| "revoked"
| "expired"
| "job-missing"
| "job-revision-changed"
| "approval-missing"
| "approval-not-allow-always";
};
/**
* Exact gateway-exec operation binding: trimmed command text, cwd, and the
* env-override hash. Both mint (approval creation) and use (allowlist
@ -427,64 +410,38 @@ function lookupCronStandingGrant(
}, params.databaseOptions);
}
/** One grant row projected for operator surfaces (list, CLI, cards). */
export type CronStandingGrantListing = CronStandingGrantRecord & {
/** Display name from the owning cron job row; null when the job is gone. */
cronJobName: string | null;
revokedAtMs: number | null;
revokedBy: string | null;
};
/**
* Lists standing grants for operator surfaces, newest first. Includes revoked
* and expired rows so the ledger explains recent history; callers render the
* state from the row facts instead of filtering here.
*/
export function listCronStandingGrants(
params: {
limit?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
} = {},
/** Includes revoked and expired grants so the operator ledger retains history. */
export function listCronStandingGrantsInDatabase(
db: DatabaseSync,
params: { limit?: number } = {},
): CronStandingGrantListing[] {
const limit = Math.max(1, Math.min(params.limit ?? 200, 500));
return runOpenClawStateWriteTransaction((database) => {
if (!tableExists(database.db, STANDING_GRANT_TABLE)) {
return [];
}
const stateDb = getNodeSqliteKysely<StandingGrantDatabase>(database.db);
const rows = executeSqliteQuerySync(
database.db,
stateDb
.selectFrom(STANDING_GRANT_TABLE)
.leftJoin("cron_jobs", "cron_jobs.job_id", "operator_approval_standing_grants.cron_job_id")
.selectAll(STANDING_GRANT_TABLE)
.select("cron_jobs.name as cron_job_name")
.orderBy("operator_approval_standing_grants.created_at_ms", "desc")
.orderBy("operator_approval_standing_grants.grant_id", "desc")
.limit(limit),
).rows;
return rows.map((row) =>
Object.assign(projectCronStandingGrant(row), {
cronJobName: row.cron_job_name ?? null,
revokedAtMs: row.revoked_at_ms,
revokedBy: row.revoked_by,
}),
);
}, params.databaseOptions);
if (!tableExists(db, STANDING_GRANT_TABLE)) {
return [];
}
const stateDb = getNodeSqliteKysely<StandingGrantDatabase>(db);
const rows = executeSqliteQuerySync(
db,
stateDb
.selectFrom(STANDING_GRANT_TABLE)
.leftJoin("cron_jobs", "cron_jobs.job_id", "operator_approval_standing_grants.cron_job_id")
.selectAll(STANDING_GRANT_TABLE)
.select("cron_jobs.name as cron_job_name")
.orderBy("operator_approval_standing_grants.created_at_ms", "desc")
.orderBy("operator_approval_standing_grants.grant_id", "desc")
.limit(limit),
).rows;
return rows.map((row) =>
Object.assign(projectCronStandingGrant(row), {
cronJobName: row.cron_job_name ?? null,
revokedAtMs: row.revoked_at_ms,
revokedBy: row.revoked_by,
}),
);
}
export type RevokeCronStandingGrantResult =
| { outcome: "revoked"; grant: CronStandingGrantListing }
| { outcome: "already-revoked" }
| { outcome: "not-found" };
/**
* Revokes one standing grant. Idempotent: a second revoke reports
* already-revoked without touching the recorded revocation provenance. The
* consume path fails closed on revoked_at_ms, so this takes effect at the
* next occurrence's spawn boundary.
*/
export function revokeCronStandingGrant(params: {
/** Repeated revocation preserves the original actor and timestamp. */
export function revokeCronStandingGrantInDatabase(params: {
grantId: string;
revokedBy: string;
nowMs?: number;

View file

@ -5,3 +5,39 @@ export type CronStandingGrantMintSpec = {
jobConfigRevision: string;
operationBinding: string;
};
export type CronStandingGrantRecord = CronStandingGrantMintSpec & {
grantId: string;
mintedByApprovalId: string;
createdAtMs: number;
/** NULL means the grant lives until revoked or superseded. */
expiresAtMs: number | null;
lastUsedAtMs: number | null;
useCount: number;
};
export type ConsumeCronStandingGrantResult =
| { outcome: "consumed"; grant: CronStandingGrantRecord }
| {
outcome:
| "no-grant"
| "revoked"
| "expired"
| "job-missing"
| "job-revision-changed"
| "approval-missing"
| "approval-not-allow-always";
};
/** One grant row projected for operator surfaces (list, CLI, cards). */
export type CronStandingGrantListing = CronStandingGrantRecord & {
/** Display name from the owning cron job row; null when the job is gone. */
cronJobName: string | null;
revokedAtMs: number | null;
revokedBy: string | null;
};
export type RevokeCronStandingGrantResult =
| { outcome: "revoked"; grant: CronStandingGrantListing }
| { outcome: "already-revoked" }
| { outcome: "not-found" };

View file

@ -8,11 +8,9 @@ import {
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db-contract.js";
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { matchesOperatorApprovalReviewerBinding } from "./operator-approval-reviewer-binding.js";
import {
OPERATOR_APPROVAL_TERMINAL_RETENTION_MS,
@ -39,6 +37,8 @@ import {
} from "./operator-approval-store.rows.js";
import { expireDueOperatorApprovalsInDatabase } from "./operator-approval-store.transitions.js";
import type {
NewOperatorApproval,
OperatorApprovalKind,
InsertOperatorApprovalResult,
GetOperatorApprovalResult,
OperatorApprovalDatabase,
@ -46,16 +46,11 @@ import type {
ListTerminalOperatorApprovalsInput,
ListTerminalOperatorApprovalsResult,
} from "./operator-approval-store.types.js";
import type { OperatorApprovalWorkerOperations } from "./operator-approval-store.worker-contract.js";
type Input<Key extends keyof OperatorApprovalWorkerOperations> =
OperatorApprovalWorkerOperations[Key]["input"] & {
databaseOptions?: OpenClawStateDatabaseOptions;
};
export function insertOperatorApprovalInDatabase(
params: Input<"operatorApprovals.insert">,
): InsertOperatorApprovalResult {
export function insertOperatorApprovalInDatabase(params: {
approval: NewOperatorApproval;
databaseOptions?: OpenClawStateDatabaseOptions;
}): InsertOperatorApprovalResult {
const input = params.approval;
const id = requireApprovalId(input.id);
const resolutionRef = buildApprovalResolutionRef({
@ -193,9 +188,12 @@ export function insertOperatorApprovalInDatabase(
}, params.databaseOptions);
}
export function getOperatorApprovalDetailedInDatabase(
params: Input<"operatorApprovals.get">,
): GetOperatorApprovalResult {
export function getOperatorApprovalDetailedInDatabase(params: {
id: string;
allowTransportRef?: boolean;
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
}): GetOperatorApprovalResult {
const locator = requireApprovalId(params.id);
return runOpenClawStateWriteTransaction((database) => {
const nowMs = params.nowMs ?? Date.now();
@ -222,7 +220,15 @@ export function getOperatorApprovalDetailedInDatabase(
}
export function listPendingOperatorApprovalsInDatabase(
params: Input<"operatorApprovals.pending"> = {},
params: {
kind?: OperatorApprovalKind;
sourceSessionKey?: string;
audienceSessionKey?: string;
reviewerDeviceId?: string;
limit?: number;
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
} = {},
): OperatorApprovalRecord[] {
expireDueOperatorApprovalsInDatabase({
nowMs: params.nowMs,

View file

@ -1,11 +1,16 @@
import { deferSqlitePostCommitPublication } from "../infra/sqlite-post-commit.js";
import { runWithSqliteWorkerStateContext } from "../infra/sqlite-worker-state-context.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import type { OpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.types.js";
import { executeOperatorApprovalOperation } from "./operator-approval-store.operations.js";
import { getOperatorApprovalResolutionKey } from "./operator-approval-store.rows.js";
import { operatorApprovalOperations } from "./operator-approval-store.operations.js";
import type { OperatorApprovalWorkerOperations } from "./operator-approval-store.worker-contract.js";
const operations: {
[Key in keyof OperatorApprovalWorkerOperations]: (
input: OperatorApprovalWorkerOperations[Key]["input"],
context: Parameters<(typeof operatorApprovalOperations)[Key]>[1],
) => OperatorApprovalWorkerOperations[Key]["output"];
} = operatorApprovalOperations;
// Retain the v2026.9.4 opaque SDK commit guard beside the same native transaction.
// Remove this branch only when that SDK contract can require a worker-safe guard.
export function executeNativeOperatorApproval<Key extends keyof OperatorApprovalWorkerOperations>(
@ -18,25 +23,10 @@ export function executeNativeOperatorApproval<Key extends keyof OperatorApproval
context.admission.assertCurrent();
return runWithSqliteWorkerStateContext(context, () => {
const options = { env: context.environment, path: context.admission.databasePath };
return runOpenClawStateWriteTransaction((database) => {
assertCurrent();
const result = executeOperatorApprovalOperation(type, input, { ...options, database });
if (
onCommitted &&
type === "operatorApprovals.resolve" &&
"outcome" in result &&
result.outcome === "resolved"
) {
const receipt = {
type: "operatorApprovals.resolve" as const,
resolutionKey: getOperatorApprovalResolutionKey(result.record),
};
if (!deferSqlitePostCommitPublication(database.db, () => onCommitted(receipt))) {
throw new Error("Operator approval commit receipt requires a transaction owner");
}
}
assertCurrent();
return result;
}, options);
return operations[type](input, {
open: () => openOpenClawStateDatabase(options),
stateOptions: () => options,
native: { assertCurrent, onCommitted },
});
});
}

View file

@ -1,33 +1,94 @@
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { deferSqlitePostCommitPublication } from "../infra/sqlite-post-commit.js";
import {
deferSqliteWorkerCommitReceipt,
requestSqliteWorkerOperationAdmission,
} from "../infra/sqlite-worker-operation-admission.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db-contract.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import type { WorkerOperationContext } from "../state/worker-operation-registry.js";
import * as grants from "./operator-approval-standing-grants.js";
import * as store from "./operator-approval-store.kernel.js";
import { getOperatorApprovalResolutionKey } from "./operator-approval-store.rows.js";
import * as transitions from "./operator-approval-store.transitions.js";
import type { OperatorApprovalWorkerOperations } from "./operator-approval-store.worker-contract.js";
type Operation = keyof OperatorApprovalWorkerOperations;
const operations: {
[Key in Operation]: (
input: OperatorApprovalWorkerOperations[Key]["input"] & {
databaseOptions?: OpenClawStateDatabaseOptions;
},
) => OperatorApprovalWorkerOperations[Key]["output"];
} = {
"operatorApprovals.insert": store.insertOperatorApprovalInDatabase,
"operatorApprovals.get": store.getOperatorApprovalDetailedInDatabase,
"operatorApprovals.pending": store.listPendingOperatorApprovalsInDatabase,
"operatorApprovals.resolve": transitions.resolveOperatorApprovalInDatabase,
"operatorApprovals.deny": transitions.forceDenyOperatorApprovalInDatabase,
"operatorApprovals.expire": transitions.expireDueOperatorApprovalsInDatabase,
"operatorApprovals.consume": transitions.consumeOperatorApprovalAllowOnceInDatabase,
type Receipt = { type: "operatorApprovals.resolve"; resolutionKey: string };
type Context = WorkerOperationContext & {
native?: { assertCurrent: () => void; onCommitted?: (receipt: Receipt) => void };
};
type Input<Handler extends (input: never) => unknown> = Omit<
NonNullable<Parameters<Handler>[0]>,
"databaseOptions"
>;
export function isOperatorApprovalOperation(type: string): type is Operation {
return Object.hasOwn(operations, type);
function transact<Payload, Result>(
input: Payload,
context: Context,
apply: (input: Payload & { databaseOptions: OpenClawStateDatabaseOptions }) => Result,
receiptOf?: (result: Result) => Receipt | undefined,
): Result {
const options = { ...context.stateOptions(), database: context.open() };
const assertCurrent = (stage: "transaction" | "commit") =>
context.native
? context.native.assertCurrent()
: requestSqliteWorkerOperationAdmission({ stage, facts: undefined });
return runOpenClawStateWriteTransaction((database) => {
assertCurrent("transaction");
const result = apply({ ...input, databaseOptions: { ...options, database } });
const receipt = receiptOf?.(result);
if (receipt) {
if (!context.native) {
deferSqliteWorkerCommitReceipt(database.db, receipt);
} else if (context.native.onCommitted) {
const publish = context.native.onCommitted;
if (!deferSqlitePostCommitPublication(database.db, () => publish(receipt))) {
throw new Error("Operator approval commit receipt requires a transaction owner");
}
}
}
assertCurrent("commit");
return result;
}, options);
}
export function executeOperatorApprovalOperation<Key extends Operation>(
type: Key,
input: OperatorApprovalWorkerOperations[Key]["input"],
databaseOptions: OpenClawStateDatabaseOptions,
): OperatorApprovalWorkerOperations[Key]["output"] {
return operations[type]({ ...input, databaseOptions });
}
export const operatorApprovalOperations = {
"operatorApprovals.insert": (
input: Input<typeof store.insertOperatorApprovalInDatabase>,
context,
) => transact(input, context, store.insertOperatorApprovalInDatabase),
"operatorApprovals.get": (
input: Input<typeof store.getOperatorApprovalDetailedInDatabase>,
context,
) => transact(input, context, store.getOperatorApprovalDetailedInDatabase),
"operatorApprovals.pending": (
input: Input<typeof store.listPendingOperatorApprovalsInDatabase>,
context,
) => transact(input, context, store.listPendingOperatorApprovalsInDatabase),
"operatorApprovals.resolve": (
input: Input<typeof transitions.resolveOperatorApprovalInDatabase>,
context,
) =>
transact(input, context, transitions.resolveOperatorApprovalInDatabase, (result) =>
result.outcome === "resolved"
? {
type: "operatorApprovals.resolve",
resolutionKey: getOperatorApprovalResolutionKey(result.record),
}
: undefined,
),
"operatorApprovals.deny": (
input: Input<typeof transitions.forceDenyOperatorApprovalInDatabase>,
context,
) => transact(input, context, transitions.forceDenyOperatorApprovalInDatabase),
"operatorApprovals.expire": (
input: Input<typeof transitions.expireDueOperatorApprovalsInDatabase>,
context,
) => transact(input, context, transitions.expireDueOperatorApprovalsInDatabase),
"operatorApprovals.consume": (
input: Input<typeof transitions.consumeOperatorApprovalAllowOnceInDatabase>,
context,
) => transact(input, context, transitions.consumeOperatorApprovalAllowOnceInDatabase),
"operatorApprovals.revokeCronGrant": (
input: Input<typeof grants.revokeCronStandingGrantInDatabase>,
context,
) => transact(input, context, grants.revokeCronStandingGrantInDatabase),
} satisfies Record<string, (input: never, context: Context) => unknown>;

View file

@ -13,7 +13,7 @@ import {
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import type { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import type { OpenClawStateDatabase } from "../state/openclaw-state-db-contract.js";
import type {
NewOperatorApproval,
OperatorApprovalDatabase,
@ -353,7 +353,7 @@ export function decodeOperatorApprovalRow(row: OperatorApprovalRow): OperatorApp
}
export function selectOperatorApprovalRow(
database: ReturnType<typeof openOpenClawStateDatabase>,
database: OpenClawStateDatabase,
id: string,
): OperatorApprovalRow | undefined {
const stateDb = getNodeSqliteKysely<OperatorApprovalDatabase>(database.db);
@ -364,7 +364,7 @@ export function selectOperatorApprovalRow(
}
export function selectOperatorApprovalRowByLocator(
database: ReturnType<typeof openOpenClawStateDatabase>,
database: OpenClawStateDatabase,
locator: string,
): OperatorApprovalRow | undefined {
const stateDb = getNodeSqliteKysely<OperatorApprovalDatabase>(database.db);
@ -380,7 +380,7 @@ export function selectOperatorApprovalRowByLocator(
}
export function hasApprovalLocatorNamespaceConflict(params: {
database: ReturnType<typeof openOpenClawStateDatabase>;
database: OpenClawStateDatabase;
id: string;
resolutionRef: string;
}): boolean {
@ -410,7 +410,7 @@ export function matchesExpectedApprovalOwner(params: {
}
export function denyCorruptPendingRow(params: {
database: ReturnType<typeof openOpenClawStateDatabase>;
database: OpenClawStateDatabase;
id: string;
nowMs: number;
createdAtMs: number;
@ -436,7 +436,7 @@ export function denyCorruptPendingRow(params: {
}
export function expirePendingRow(params: {
database: ReturnType<typeof openOpenClawStateDatabase>;
database: OpenClawStateDatabase;
id: string;
nowMs: number;
createdAtMs: number;

View file

@ -32,7 +32,7 @@ import {
pruneTerminalOperatorApprovals,
resolveOperatorApproval,
} from "./operator-approval-store.js";
import { executeOperatorApprovalCommand } from "./operator-approval-store.worker.js";
import { operatorApprovalOperations } from "./operator-approval-store.operations.js";
type OperatorApprovalDatabase = Pick<OpenClawStateKyselyDatabase, "operator_approvals">;
type NewOperatorApproval = Parameters<typeof insertOperatorApproval>[0]["approval"];
@ -422,9 +422,12 @@ describe("operator approval store", () => {
writer.exec("BEGIN IMMEDIATE");
expect(Date.now()).toBeLessThan(expiresAtMs);
const result = await withSqliteWriteAdmissionService(database.db, releaseWriter, async () =>
executeOperatorApprovalCommand(
{ type: "operatorApprovals.get", input: { id: "lock-delayed-clock" } },
databaseOptions,
operatorApprovalOperations["operatorApprovals.get"](
{ id: "lock-delayed-clock" },
{
open: () => database,
stateOptions: () => ({ path: database.path, env: databaseOptions.env ?? process.env }),
},
),
);

View file

@ -2,11 +2,10 @@
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
import { mintMcpToolGrantLocked } from "../infra/exec-approvals-sqlite.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db-contract.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { mintCronStandingGrantLocked } from "./operator-approval-standing-grants.js";
import type { CronStandingGrantMintSpec } from "./operator-approval-standing-grants.types.js";
import {
OPERATOR_APPROVAL_TERMINAL_RETENTION_MS,
requireApprovalId,
@ -21,6 +20,10 @@ import {
isValidTimestamp,
} from "./operator-approval-store.rows.js";
import type {
OperatorApprovalDecision,
OperatorApprovalKind,
OperatorApprovalResolver,
OperatorApprovalTerminalReason,
OperatorApprovalDatabase,
OperatorApprovalRecord,
OperatorApprovalRow,
@ -29,16 +32,21 @@ import type {
TerminalizeOperatorApprovalsResult,
ConsumeOperatorApprovalResult,
} from "./operator-approval-store.types.js";
import type { OperatorApprovalWorkerOperations } from "./operator-approval-store.worker-contract.js";
type Input<Key extends keyof OperatorApprovalWorkerOperations> =
OperatorApprovalWorkerOperations[Key]["input"] & {
databaseOptions?: OpenClawStateDatabaseOptions;
};
export function resolveOperatorApprovalInDatabase(
params: Input<"operatorApprovals.resolve">,
): ResolveOperatorApprovalResult {
export function resolveOperatorApprovalInDatabase(params: {
id: string;
decision: OperatorApprovalDecision;
resolver: OperatorApprovalResolver;
expectedKind?: OperatorApprovalKind;
runtimeEpoch?: string;
nowMs?: number;
mcpToolGrant?: { agentId: string; server: string; tool: string };
/** Cron-context allow-always mints this scoped grant in the same transaction. */
standingGrant?: { kind: "cron" } & CronStandingGrantMintSpec & {
expiresAtMs: number | null;
};
databaseOptions?: OpenClawStateDatabaseOptions;
}): ResolveOperatorApprovalResult {
const id = requireApprovalId(params.id);
const resolverId = normalizeNullableString(params.resolver.id);
const runtimeEpoch =
@ -146,9 +154,17 @@ export function resolveOperatorApprovalInDatabase(
}, params.databaseOptions);
}
export function forceDenyOperatorApprovalInDatabase(
params: Input<"operatorApprovals.deny">,
): ForceDenyOperatorApprovalResult {
export function forceDenyOperatorApprovalInDatabase(params: {
id: string;
status?: "denied" | "expired" | "cancelled";
requireDue?: boolean;
reason: OperatorApprovalTerminalReason;
resolver: OperatorApprovalResolver;
expectedKind?: OperatorApprovalKind;
runtimeEpoch?: string;
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
}): ForceDenyOperatorApprovalResult {
const id = requireApprovalId(params.id);
const runtimeEpoch =
params.runtimeEpoch === undefined
@ -217,9 +233,10 @@ export function forceDenyOperatorApprovalInDatabase(
}, params.databaseOptions);
}
export function expireDueOperatorApprovalsInDatabase(
params: Input<"operatorApprovals.expire">,
): TerminalizeOperatorApprovalsResult {
export function expireDueOperatorApprovalsInDatabase(params: {
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
}): TerminalizeOperatorApprovalsResult {
return runOpenClawStateWriteTransaction((database) => {
const nowMs = params.nowMs ?? Date.now();
const stateDb = getNodeSqliteKysely<OperatorApprovalDatabase>(database.db);
@ -320,9 +337,15 @@ export function closeOrphanedOperatorApprovals(params: {
}, params.databaseOptions);
}
export function consumeOperatorApprovalAllowOnceInDatabase(
params: Input<"operatorApprovals.consume">,
): ConsumeOperatorApprovalResult {
export function consumeOperatorApprovalAllowOnceInDatabase(params: {
id: string;
consumerId: string;
expectedKind?: OperatorApprovalKind;
runtimeEpoch?: string;
redemptionWindowMs?: number;
nowMs?: number;
databaseOptions?: OpenClawStateDatabaseOptions;
}): ConsumeOperatorApprovalResult {
const id = requireApprovalId(params.id);
const consumerId = requireString(params.consumerId, "operator approval consumer id");
const runtimeEpoch =

View file

@ -21,6 +21,10 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { isStateDatabaseReadAdmissionInvalidatedError } from "../state/openclaw-state-db-async-lifecycle.js";
import { executeExistingOpenClawStateRead } from "../state/openclaw-state-db-readonly.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import type {
OpenClawStateReadCommand,
OpenClawStateReadResult,
} from "../state/openclaw-state-read.types.js";
import { captureOpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.js";
import type { OpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.types.js";
import type { OpenClawStateWorkerOperationOptions } from "../state/openclaw-state-worker-contract.js";
@ -224,18 +228,16 @@ export function consumeOperatorApprovalAllowOnce(params: Input<"operatorApproval
return execute("operatorApprovals.consume", input, { databaseOptions, assertCurrent, guard });
}
export async function listTerminalOperatorApprovals(
params: ListTerminalOperatorApprovalsInput & Options = {},
): Promise<ListTerminalOperatorApprovalsResult> {
const { databaseOptions, assertCurrent, guard, ...input } = params;
if (input.cursor !== undefined) {
decodeOperatorApprovalHistoryCursor(input.cursor);
}
async function readApprovalStore<T>(
command: Extract<OpenClawStateReadCommand, { type: `operatorApprovals.${string}` }>,
{ databaseOptions, assertCurrent, guard }: Options,
project: (result: OpenClawStateReadResult) => T | undefined,
): Promise<T> {
const context = captureOpenClawStateWorkerContext({
...databaseOptions,
path: databaseOptions?.database?.path ?? databaseOptions?.path,
});
const captured = structuredClone(input);
const captured = structuredClone(command);
const assertOperationCurrent = () => {
context.admission.assertCurrent();
guard?.assertCurrent();
@ -251,15 +253,48 @@ export async function listTerminalOperatorApprovals(
preparation.release();
const result = await executeExistingOpenClawStateRead(
{ env: context.environment, path: context.admission.databasePath },
{ type: "operatorApprovals.history", input: captured },
captured,
);
assertOperationCurrent();
if (result?.ok && result.type === "operatorApprovals.history") {
return result.history;
const value = result?.ok && result.type === command.type ? project(result) : undefined;
if (value !== undefined) {
return value;
}
throw new Error("Operator approval history database became unavailable");
throw new Error("Operator approval database became unavailable");
},
undefined,
assertOperationCurrent,
);
}
export async function listTerminalOperatorApprovals(
params: ListTerminalOperatorApprovalsInput & Options = {},
): Promise<ListTerminalOperatorApprovalsResult> {
const { databaseOptions, assertCurrent, guard, ...input } = params;
if (input.cursor !== undefined) {
decodeOperatorApprovalHistoryCursor(input.cursor);
}
return readApprovalStore(
{ type: "operatorApprovals.history", input },
{ databaseOptions, assertCurrent, guard },
(result) => (result.type === "operatorApprovals.history" ? result.history : undefined),
);
}
export function listCronStandingGrants(params: { limit?: number } & Options = {}) {
const { databaseOptions, assertCurrent, guard, ...input } = params;
return readApprovalStore(
{ type: "operatorApprovals.listCronGrants", input },
{ databaseOptions, assertCurrent, guard },
(result) => (result.type === "operatorApprovals.listCronGrants" ? result.grants : undefined),
);
}
export function revokeCronStandingGrant(params: Input<"operatorApprovals.revokeCronGrant">) {
const { databaseOptions, assertCurrent, guard, ...input } = params;
return execute("operatorApprovals.revokeCronGrant", input, {
databaseOptions,
assertCurrent,
guard,
});
}

View file

@ -1,81 +1,4 @@
import type { CronStandingGrantMintSpec } from "./operator-approval-standing-grants.types.js";
import type {
ConsumeOperatorApprovalResult,
ForceDenyOperatorApprovalResult,
GetOperatorApprovalResult,
InsertOperatorApprovalResult,
NewOperatorApproval,
OperatorApprovalDecision,
OperatorApprovalKind,
OperatorApprovalRecord,
OperatorApprovalResolver,
OperatorApprovalTerminalReason,
ResolveOperatorApprovalResult,
TerminalizeOperatorApprovalsResult,
} from "./operator-approval-store.types.js";
import type { WorkerOperations } from "../state/worker-operation-registry.js";
import type { operatorApprovalOperations } from "./operator-approval-store.operations.js";
export type OperatorApprovalWorkerOperations = {
"operatorApprovals.insert": {
input: { approval: NewOperatorApproval };
output: InsertOperatorApprovalResult;
};
"operatorApprovals.get": {
input: { id: string; allowTransportRef?: boolean; nowMs?: number };
output: GetOperatorApprovalResult;
};
"operatorApprovals.pending": {
input: {
kind?: OperatorApprovalKind;
sourceSessionKey?: string;
audienceSessionKey?: string;
reviewerDeviceId?: string;
limit?: number;
nowMs?: number;
};
output: OperatorApprovalRecord[];
};
"operatorApprovals.resolve": {
input: {
id: string;
decision: OperatorApprovalDecision;
resolver: OperatorApprovalResolver;
expectedKind?: OperatorApprovalKind;
runtimeEpoch?: string;
nowMs?: number;
mcpToolGrant?: { agentId: string; server: string; tool: string };
/** Cron-context allow-always mints this scoped grant in the same transaction. */
standingGrant?: { kind: "cron" } & CronStandingGrantMintSpec & {
expiresAtMs: number | null;
};
};
output: ResolveOperatorApprovalResult;
};
"operatorApprovals.deny": {
input: {
id: string;
status?: "denied" | "expired" | "cancelled";
requireDue?: boolean;
reason: OperatorApprovalTerminalReason;
resolver: OperatorApprovalResolver;
expectedKind?: OperatorApprovalKind;
runtimeEpoch?: string;
nowMs?: number;
};
output: ForceDenyOperatorApprovalResult;
};
"operatorApprovals.expire": {
input: { nowMs?: number };
output: TerminalizeOperatorApprovalsResult;
};
"operatorApprovals.consume": {
input: {
id: string;
consumerId: string;
expectedKind?: OperatorApprovalKind;
runtimeEpoch?: string;
redemptionWindowMs?: number;
nowMs?: number;
};
output: ConsumeOperatorApprovalResult;
};
};
export type OperatorApprovalWorkerOperations = WorkerOperations<typeof operatorApprovalOperations>;

View file

@ -1,44 +0,0 @@
import type { SqliteWorkerCommand } from "../infra/sqlite-worker-contract.js";
import {
deferSqliteWorkerCommitReceipt,
requestSqliteWorkerOperationAdmission,
} from "../infra/sqlite-worker-operation-admission.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import {
executeOperatorApprovalOperation,
isOperatorApprovalOperation,
} from "./operator-approval-store.operations.js";
import { getOperatorApprovalResolutionKey } from "./operator-approval-store.rows.js";
import type { OperatorApprovalWorkerOperations } from "./operator-approval-store.worker-contract.js";
export function isOperatorApprovalCommand(command: {
type: string;
}): command is SqliteWorkerCommand<OperatorApprovalWorkerOperations> {
return isOperatorApprovalOperation(command.type);
}
export function executeOperatorApprovalCommand(
command: SqliteWorkerCommand<OperatorApprovalWorkerOperations>,
databaseOptions: OpenClawStateDatabaseOptions,
): OperatorApprovalWorkerOperations[keyof OperatorApprovalWorkerOperations]["output"] {
return runOpenClawStateWriteTransaction((database) => {
requestSqliteWorkerOperationAdmission({ stage: "transaction", facts: undefined });
const options = { ...databaseOptions, database };
const result = executeOperatorApprovalOperation(command.type, command.input, options);
if (
command.type === "operatorApprovals.resolve" &&
"outcome" in result &&
result.outcome === "resolved"
) {
deferSqliteWorkerCommitReceipt(database.db, {
type: command.type,
resolutionKey: getOperatorApprovalResolutionKey(result.record),
});
}
requestSqliteWorkerOperationAdmission({ stage: "commit", facts: undefined });
return result;
}, databaseOptions);
}

View file

@ -6,6 +6,7 @@ import type { OpenClawStateDatabaseOptions } from "../../state/openclaw-state-db
import { invalidateGatewayDeviceRevocation } from "../device-revocation.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import { createPreparedTestApprovalManager } from "../exec-approval-manager.test-support.js";
import * as approvalStore from "../operator-approval-store.js";
import * as recordLookup from "./approval-record-lookup.js";
import {
createApprovalInvocation,
@ -21,6 +22,70 @@ import type { GatewayRequestHandlers } from "./types.js";
afterEach(() => vi.restoreAllMocks());
it.for(["list", "revoke"] as const)(
"rechecks grant %s RPC authority after storage settles",
async (operation, test) => {
const fixture = await createExecApprovalFixture(test);
await fixture.run(async () => {
const client = createClient({ deviceId: "grant-reviewer", scopes: ["operator.admin"] });
const invocation = createApprovalInvocation({
handlers: fixture.handlers,
method: operation === "list" ? "exec.approval.grants.list" : "exec.approval.grants.revoke",
body: operation === "list" ? {} : { grantId: "missing" },
client,
});
const list = approvalStore.listCronStandingGrants;
const revoke = approvalStore.revokeCronStandingGrant;
if (operation === "list") {
vi.spyOn(approvalStore, "listCronStandingGrants").mockImplementationOnce(async (params) => {
const result = await list({ ...params, databaseOptions: fixture.databaseOptions });
client.invalidated = true;
return result;
});
} else {
vi.spyOn(approvalStore, "revokeCronStandingGrant").mockImplementationOnce(
async (params) => {
const result = await revoke({ ...params, databaseOptions: fixture.databaseOptions });
client.invalidated = true;
return result;
},
);
}
await expect(invocation.invoke()).rejects.toThrow(/authority/i);
expect(invocation.respond).not.toHaveBeenCalled();
});
},
);
it("refuses grant revocation when the RPC authority changes at worker commit", async (test) => {
const fixture = await createExecApprovalFixture(test);
await fixture.run(async () => {
const client = createClient({ deviceId: "grant-commit-reviewer", scopes: ["operator.admin"] });
const invocation = createApprovalInvocation({
handlers: fixture.handlers,
method: "exec.approval.grants.revoke",
body: { grantId: "missing" },
client,
});
const revoke = approvalStore.revokeCronStandingGrant;
vi.spyOn(approvalStore, "revokeCronStandingGrant").mockImplementationOnce((params) =>
revoke({ ...params, databaseOptions: fixture.databaseOptions }),
);
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
vi.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission").mockImplementation(
(admit, attachment) =>
createAdmission((request, grant) => {
if (request.stage === "commit") {
client.invalidated = true;
}
return admit(request, grant);
}, attachment),
);
await expect(invocation.invoke()).rejects.toThrow(/authority/i);
expect(invocation.respond).not.toHaveBeenCalled();
});
});
type LegacyReadMethod = "exec.approval.get" | "exec.approval.list" | "plugin.approval.list";
async function proveLegacyResponseAuthority<TPayload>(

View file

@ -111,6 +111,8 @@ type ApprovalInvocationParams = {
| "approval.resolve"
| "exec.approval.get"
| "exec.approval.list"
| "exec.approval.grants.list"
| "exec.approval.grants.revoke"
| "exec.approval.resolve"
| "exec.approval.waitDecision"
| "plugin.approval.list"

View file

@ -36,10 +36,9 @@ import type { ExecApprovalManager } from "../exec-approval-manager.js";
import { InvalidApprovalIdError } from "../exec-approval-registration.js";
import {
buildCronExecOperationBinding,
listCronStandingGrants,
parseCronExecOperationBinding,
revokeCronStandingGrant,
} from "../operator-approval-standing-grants.js";
import { listCronStandingGrants, revokeCronStandingGrant } from "../operator-approval-store.js";
import { resolveGrantExpiryDaysConfig } from "../standing-grant-expiry-config.js";
import { createApprovalRequestAuthority } from "./approval-request-authority.js";
import { handlePendingApprovalRequestWithDelivery } from "./approval-request-delivery.js";
@ -449,7 +448,9 @@ export function createExecApprovalHandlers(
},
});
},
"exec.approval.grants.list": async ({ params, respond }) => {
"exec.approval.grants.list": async (options) => {
using authority = createApprovalRequestAuthority(options);
const { params, respond } = options;
if (
!assertValidParams(
params,
@ -460,8 +461,9 @@ export function createExecApprovalHandlers(
) {
return;
}
const { limit } = params;
const grants = listCronStandingGrants(limit ? { limit } : {}).map((grant) => {
const records = await listCronStandingGrants({ limit: params.limit, guard: authority.guard });
authority.assertCurrent();
const grants = records.map((grant) => {
const operation = parseCronExecOperationBinding(grant.operationBinding);
return {
grantId: grant.grantId,
@ -484,7 +486,9 @@ export function createExecApprovalHandlers(
});
respond(true, { grants }, undefined);
},
"exec.approval.grants.revoke": async ({ params, respond, client }) => {
"exec.approval.grants.revoke": async (options) => {
using authority = createApprovalRequestAuthority(options);
const { params, respond, client } = options;
if (
!assertValidParams(
params,
@ -495,10 +499,14 @@ export function createExecApprovalHandlers(
) {
return;
}
// Same actor attribution as approval resolution; recorded for the ledger.
const revokedBy =
client?.connect?.client?.displayName ?? client?.connect?.client?.id ?? "operator";
const result = revokeCronStandingGrant({ grantId: params.grantId, revokedBy });
const result = await revokeCronStandingGrant({
grantId: params.grantId,
revokedBy,
guard: authority.guard,
});
authority.assertCurrent();
respond(true, { outcome: result.outcome }, undefined);
},
"exec.approval.resolve": async (options) => {

View file

@ -1,5 +1,4 @@
import { sha256HexPrefixCore } from "./crypto-digest.js";
// Owns durable approval matching and allow-always persistence.
import {
buildExecApprovalPolicyRuleKey,
canonicalizeExecApprovalPolicyRules,

View file

@ -30,7 +30,7 @@ import {
type ExecCommandSegment,
type ExecutableResolution,
} from "./exec-approvals-analysis.js";
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
import type { AllowAlwaysPattern, ExecAllowlistEntry } from "./exec-approvals.types.js";
import {
canUseReusableWrapperPayloadCandidates,
planShellAuthorization,
@ -1053,11 +1053,6 @@ function resolveShellWrapperPositionalArgvCandidate(params: {
};
}
export type AllowAlwaysPattern = {
pattern: string;
argPattern?: string;
};
function buildScriptArgPatternFromArgv(
argv: string[],
scriptPath: string,

View file

@ -0,0 +1,6 @@
import type { WorkerOperations } from "../state/worker-operation-registry.js";
import type { execAuthorizationOperations } from "./exec-approvals-authorization.worker.js";
export type ExecAuthorizationWorkerOperations = WorkerOperations<
typeof execAuthorizationOperations
>;

View file

@ -1,26 +1,26 @@
import type { DatabaseSync } from "node:sqlite";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db-contract.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import type { WorkerOperationHandlers } from "../state/worker-operation-registry.js";
import { applyExecAuthorizationCommit } from "./exec-approvals-authorization.kernel.js";
import { resolveExecApprovalsDisplayPath } from "./exec-approvals-config.js";
import type {
ExecAuthorizationCommitInput,
ExecAuthorizationCommitOutcome,
ExecAuthorizationWorkerOperations,
} from "./exec-approvals-contracts.js";
import type { ExecApprovalsSnapshot } from "./exec-approvals-core.js";
import { assertNoPendingLegacyExecApprovals } from "./exec-approvals-migration-gate.js";
import {
snapshotFromExecApprovalsDatabase,
assertExecApprovalsMutationAllowed,
ExecApprovalsMutationFencedError,
serializeExecApprovals,
snapshotFromExecApprovalsRow,
writeExecApprovalsConfigRow,
} from "./exec-approvals-sqlite.js";
import { snapshotFromExecApprovalsDatabase } from "./exec-approvals-store.js";
function applyAuthorizationBatch(
db: DatabaseSync,
@ -55,7 +55,7 @@ function applyAuthorizationBatch(
}
export function commitExecAuthorizationsInWorker(
input: ExecAuthorizationWorkerOperations["execApprovals.commitAuthorizations"]["input"],
input: { items: ExecAuthorizationCommitInput[] },
options: OpenClawStateDatabaseOptions,
): ExecAuthorizationCommitOutcome[] {
assertNoPendingLegacyExecApprovals({ env: options.env });
@ -85,3 +85,10 @@ export function commitExecAuthorizationsInWorker(
{ operationLabel: "exec-approvals.commit-authorizations" },
);
}
export const execAuthorizationOperations = {
"execApprovals.commitAuthorizations": (
input: { items: ExecAuthorizationCommitInput[] },
{ open, stateOptions },
) => commitExecAuthorizationsInWorker(input, { ...stateOptions(), database: open() }),
} satisfies WorkerOperationHandlers;

View file

@ -1,8 +1,7 @@
// Shared type contracts for exec approval policy and durable persistence.
import type { ExecApprovalPolicySnapshot } from "./exec-approval-policy-snapshot.js";
import type { AllowAlwaysPattern } from "./exec-approvals-allowlist.js";
import type { ExecApprovalsSnapshot, ExecAsk, ExecSecurity } from "./exec-approvals-core.js";
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
import type { AllowAlwaysPattern, ExecAllowlistEntry } from "./exec-approvals.types.js";
export type ExecApprovalsDefaultOverrides = {
security?: ExecSecurity;
@ -47,10 +46,3 @@ export type ExecAuthorizationCommitInput = {
export type ExecAuthorizationCommitOutcome =
| { ok: true; snapshot: ExecApprovalsSnapshot }
| { ok: false; message: string };
export type ExecAuthorizationWorkerOperations = {
"execApprovals.commitAuthorizations": {
input: { items: ExecAuthorizationCommitInput[] };
output: ExecAuthorizationCommitOutcome[];
};
};

View file

@ -1,14 +1,18 @@
// Canonical SQLite row helpers for exec approval policy state.
import type { DatabaseSync } from "node:sqlite";
import { isDeepStrictEqual } from "node:util";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import { sha256Hex } from "./crypto-digest.js";
import { formatErrorMessage } from "./errors.js";
import {
normalizeExecApprovalsInternal,
resolveExecApprovalsDisplayPath,
tryParsePersistedExecApprovals,
} from "./exec-approvals-config.js";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./exec-approvals-core.js";
import { resetExecApprovalsMigrationGateForTest } from "./exec-approvals-migration-gate.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@ -261,3 +265,39 @@ export function mintMcpToolGrantLocked(
assertExecApprovalsMutationAllowed({ db, current, next });
writeExecApprovalsConfigRow({ db, file: next, now: nowMs });
}
const log = createSubsystemLogger("infra/exec-approvals");
const WARN_INTERVAL_MS = 60_000;
let lastWarnAt: number | undefined;
export function warnFailClosed(message: string, error?: unknown): void {
const now = Date.now();
if (lastWarnAt !== undefined && now - lastWarnAt < WARN_INTERVAL_MS) {
return;
}
lastWarnAt = now;
log.warn(message, error === undefined ? undefined : { error: formatErrorMessage(error) });
}
export function snapshotFromExecApprovalsDatabase(
db: DatabaseSync,
displayPath = resolveExecApprovalsDisplayPath(),
): ExecApprovalsSnapshot {
return snapshotFromExecApprovalsRow({
path: displayPath,
row: readExecApprovalsConfigRow(db),
onMalformed: () =>
warnFailClosed("exec approvals SQLite row is malformed; denying host execution"),
});
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
Object.assign(globalThis, {
[Symbol.for("openclaw.execApprovalsStoreTestApi")]: {
reset(): void {
resetExecApprovalsMigrationGateForTest();
lastWarnAt = undefined;
},
},
});
}

View file

@ -3,7 +3,6 @@ import {
AgentDeletionAuthorityRollbackError,
AgentDeletionCommitUncertainError,
} from "../agents/agent-lifecycle-registry.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAgentId, normalizeAgentIdStrict } from "../routing/session-key.js";
import { createDeferredCore } from "../shared/deferred.js";
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
@ -24,7 +23,6 @@ import {
import { captureOpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.js";
import type { OpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.types.js";
import { runOpenClawStateWorkerOperation } from "../state/openclaw-state-worker-store.js";
import { formatErrorMessage } from "./errors.js";
import {
createFailClosedExecApprovalsFallback,
generateToken,
@ -37,9 +35,10 @@ import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./exec-approvals-
import {
assertNoPendingLegacyExecApprovals,
ExecApprovalsMigrationRequiredError,
resetExecApprovalsMigrationGateForTest,
} from "./exec-approvals-migration-gate.js";
import {
snapshotFromExecApprovalsDatabase,
warnFailClosed,
assertExecApprovalsMutationAllowed,
assertExecApprovalsMutationAuthority,
deleteExecApprovalsConfigRow,
@ -51,10 +50,6 @@ import {
writeExecApprovalsConfigRow,
} from "./exec-approvals-sqlite.js";
const log = createSubsystemLogger("infra/exec-approvals");
const WARN_INTERVAL_MS = 60_000;
let lastWarnAt: number | undefined;
class ExecApprovalsStoreUnavailableError extends Error {
constructor(cause: unknown) {
super(`Exec approvals SQLite state is unavailable: ${String(cause)}`, { cause });
@ -62,31 +57,6 @@ class ExecApprovalsStoreUnavailableError extends Error {
}
}
function warnFailClosed(message: string, error?: unknown): void {
const now = Date.now();
if (lastWarnAt !== undefined && now - lastWarnAt < WARN_INTERVAL_MS) {
return;
}
lastWarnAt = now;
if (error === undefined) {
log.warn(message);
} else {
log.warn(message, { error: formatErrorMessage(error) });
}
}
export function snapshotFromExecApprovalsDatabase(
db: ReturnType<typeof openOpenClawStateDatabase>["db"],
displayPath = resolveExecApprovalsDisplayPath(),
): ExecApprovalsSnapshot {
return snapshotFromExecApprovalsRow({
path: displayPath,
row: readExecApprovalsConfigRow(db),
onMalformed: () =>
warnFailClosed("exec approvals SQLite row is malformed; denying host execution"),
});
}
function readExecApprovalsSnapshotFromDatabase(
options: OpenClawStateDatabaseOptions = {},
): ExecApprovalsSnapshot {
@ -506,15 +476,3 @@ function ensureExecApprovalsSnapshotSync(): ExecApprovalsSnapshot {
export async function ensureExecApprovalsSnapshot(): Promise<ExecApprovalsSnapshot> {
return ensureExecApprovalsSnapshotSync();
}
const testing = {
reset(): void {
resetExecApprovalsMigrationGateForTest();
lastWarnAt = undefined;
},
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.execApprovalsStoreTestApi")] =
testing;
}

View file

@ -19,7 +19,7 @@ export * from "./exec-approvals-allowlist.js";
export * from "./exec-approvals-core.js";
export * from "./exec-approvals-generated-migration.js";
export type { ExecApprovalPolicySnapshot } from "./exec-approval-policy-snapshot.js";
export type { ExecAllowlistEntry } from "./exec-approvals.types.js";
export type { AllowAlwaysPattern, ExecAllowlistEntry } from "./exec-approvals.types.js";
export type { ExecApprovalsDefaultOverrides } from "./exec-approvals-contracts.js";
export {
DEFAULT_EXEC_APPROVAL_ASK_FALLBACK,

View file

@ -18,3 +18,8 @@ export type ExecAllowlistEntry = {
lastUsedCommand?: string;
lastResolvedPath?: string;
};
export type AllowAlwaysPattern = {
pattern: string;
argPattern?: string;
};

View file

@ -1,12 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { sha256Hex } from "./crypto-digest.js";
import type { ExecCommandSegment } from "./exec-approvals-analysis.js";
// Binds system-run approval requests to stable command identities.
import type {
ExecCommandSegment,
SystemRunApprovalBinding,
SystemRunApprovalFileOperand,
} from "./exec-approvals.js";
} from "./exec-approvals-core.js";
import { planShellAuthorization } from "./exec-authorization-plan.js";
import {
type ExecutableResolution,

View file

@ -1,7 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { SystemRunApprovalFileOperand } from "./exec-approvals.js";
import type { SystemRunApprovalFileOperand } from "./exec-approvals-core.js";
function hashFileContentsSync(filePath: string): string {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");

View file

@ -125,8 +125,11 @@ export function captureCommand(command: OpenClawStateReadCommand): OpenClawState
if (command.type === "devicePairing.bootstrapContext") {
return { ...command, input: { ...command.input } };
}
if (command.type === "operatorApprovals.history") {
return { ...command, input: { ...command.input } };
if (
command.type === "operatorApprovals.history" ||
command.type === "operatorApprovals.listCronGrants"
) {
return structuredClone(command);
}
if (
command.type === "acpSessions.metadata" ||
@ -370,6 +373,9 @@ function commandBytes(command: OpenClawStateReadRequest["command"]): number {
16
);
}
if (command.type === "operatorApprovals.listCronGrants") {
return bytes + 8;
}
if (command.type === "deliveryQueue.outbound") {
return bytes + Buffer.byteLength(command.id ?? "", "utf8");
}

View file

@ -39,6 +39,7 @@ import type {
} from "../cron/store/run-recovery-read.types.js";
import type { CronQuarantinedJob } from "../cron/types-shared.js";
import type { FleetCellRecord } from "../fleet/registry.types.js";
import type { CronStandingGrantListing } from "../gateway/operator-approval-standing-grants.types.js";
import type {
ListTerminalOperatorApprovalsInput,
ListTerminalOperatorApprovalsResult,
@ -165,6 +166,7 @@ export type OpenClawStateReadCommand =
type: "operatorApprovals.history";
input: ListTerminalOperatorApprovalsInput;
}
| { type: "operatorApprovals.listCronGrants"; input: { limit?: number } }
| PluginBlobReadCommand
| { type: "subagents.sessionList" }
| {
@ -311,6 +313,7 @@ export type OpenClawStateReadResult =
type: "operatorApprovals.history";
history: ListTerminalOperatorApprovalsResult;
}
| { type: "operatorApprovals.listCronGrants"; grants: CronStandingGrantListing[] }
| ReadResult<PluginBlobReadReply>
| {
type: "capture.readOnlyEvents";

View file

@ -273,7 +273,9 @@ export function isReadRequest(input: unknown): input is OpenClawStateReadRequest
(input.command.input.includeRunId === undefined ||
typeof input.command.input.includeRunId === "string")) ||
input.command.type === "fleet.list" ||
(input.command.type === "operatorApprovals.history" && isRecord(input.command.input)) ||
((input.command.type === "operatorApprovals.history" ||
input.command.type === "operatorApprovals.listCronGrants") &&
isRecord(input.command.input)) ||
isTuiLastSessionReadCommand(input.command) ||
input.command.type === "nodeHost.config" ||
input.command.type === "operator.channelPolicy" ||

View file

@ -38,6 +38,7 @@ import {
readKnownRepositoryGitHubPublicationPullRequestUrlsInDatabase,
readRepositoryGitHubPublicationInDatabase,
} from "../gateway/github-repository-publication-store.js";
import { listCronStandingGrantsInDatabase } from "../gateway/operator-approval-standing-grants.js";
import { listTerminalOperatorApprovalsInDatabase } from "../gateway/operator-approval-store.kernel.js";
import { readSessionGroupCatalogSnapshot } from "../gateway/session-group-catalog.kernel.js";
import { readSessionGroupMembership } from "../gateway/session-group-membership.read.js";
@ -482,6 +483,12 @@ serveOwnedWorkerTasks(
history: listTerminalOperatorApprovalsInDatabase(command.input, db),
};
}
if (command.type === "operatorApprovals.listCronGrants") {
return {
type: command.type,
grants: listCronStandingGrantsInDatabase(db, command.input),
};
}
if (command.type === "onboardingRecommendations.read") {
return {
type: command.type,

View file

@ -19,7 +19,6 @@ import type {
RepositoryGitHubPublicationPendingQuery,
RepositoryGitHubPublicationStatusRow,
} from "../gateway/github-repository-publication.kernel.js";
import type { OperatorApprovalWorkerOperations } from "../gateway/operator-approval-store.worker-contract.js";
import type {
SessionGroupCatalogMutation,
SessionGroupCatalogMutationResult,
@ -32,7 +31,6 @@ import type { WorkspaceJournalWorkerOperations } from "../gateway/worker-environ
import type { WorkerEnvironmentWorkerOperations } from "../gateway/worker-environments/store-worker-contract.js";
import type * as deviceAuth from "../infra/device-auth-store.kernel.js";
import type { DeviceIdentity } from "../infra/device-identity-store.js";
import type { ExecAuthorizationWorkerOperations } from "../infra/exec-approvals-contracts.js";
import type { PreparedSqliteAuditRecord } from "../infra/sqlite-audit-record.kernel.js";
import type { SqliteFileGeneration } from "../infra/sqlite-file-generation.js";
import type {
@ -83,8 +81,6 @@ export type OpenClawStateWorkerOperations = RegisteredStateWorkerOperations &
TuiLastSessionWorkerOperations &
SessionStateWorkerOperations &
SessionUpstreamWorkerOperations &
ExecAuthorizationWorkerOperations &
OperatorApprovalWorkerOperations &
PluginStateWorkerOperations &
UserPreferenceWorkerOperations &
CronStateWorkerOperations &

View file

@ -8,8 +8,10 @@ import type { ChannelIngressWorkerOperations } from "../channels/message/ingress
import type { DoctorWorkerOperations } from "../commands/doctor-state.worker.js";
import type { FleetRegistryWriteOperations } from "../fleet/registry.worker-contract.js";
import type { ManagedImageRecordWorkerOperations } from "../gateway/managed-image-record-store.kernel.js";
import type { OperatorApprovalWorkerOperations } from "../gateway/operator-approval-store.worker-contract.js";
import type { DeliveryQueueWorkerOperations } from "../infra/delivery-queue.worker-contract.js";
import type { DevicePairingWorkerOperations } from "../infra/device-pairing-worker-contract.js";
import type { ExecAuthorizationWorkerOperations } from "../infra/exec-approvals-authorization.worker-contract.js";
import type { CurrentConversationBindingWorkerOperations } from "../infra/outbound/current-conversation-bindings.worker.js";
import type { PromotionWorkerOperations } from "../infra/promotions-feed.worker.js";
import type { ApnsRegistrationWorkerOperations } from "../infra/push-apns-store.worker-contract.js";
@ -35,6 +37,8 @@ export type RegisteredStateWorkerOperations = WebPushWorkerOperations &
ApnsRegistrationWorkerOperations &
WorktreeWorkerOperations &
FleetRegistryWriteOperations &
OperatorApprovalWorkerOperations &
ExecAuthorizationWorkerOperations &
DeliveryQueueWorkerOperations &
SessionDeliveryWorkerOperations &
CurrentConversationBindingWorkerOperations &
@ -62,6 +66,14 @@ export type RegisteredStateWorkerOperations = WebPushWorkerOperations &
UserProfileWorkerOperations;
export const stateWorkerRegistry = createWorkerOperationRegistry<RegisteredStateWorkerOperations>({
operatorApprovals: () =>
import("../gateway/operator-approval-store.operations.js").then(
(m) => m.operatorApprovalOperations,
),
execApprovals: () =>
import("../infra/exec-approvals-authorization.worker.js").then(
(m) => m.execAuthorizationOperations,
),
userProfiles: () => import("./user-profiles.worker.js").then((m) => m.userProfileOperations),
authProfiles: () =>
import("../agents/auth-profiles/store.worker.js").then((m) => m.authProfileOperations),

View file

@ -14,10 +14,6 @@ import {
prepareCronStateWorkerCommand,
} from "../cron/store/dispatch.worker.js";
import { readPendingRepositoryGitHubPublicationInDatabase } from "../gateway/github-repository-publication.kernel.js";
import {
executeOperatorApprovalCommand,
isOperatorApprovalCommand,
} from "../gateway/operator-approval-store.worker.js";
import { mutateSessionGroupCatalogInDatabase } from "../gateway/session-group-catalog.kernel.js";
import { isWorkerInferenceStoreCommand } from "../gateway/worker-environments/inference-store.worker-contract.js";
import { executeWorkerInferenceStoreCommand } from "../gateway/worker-environments/inference-store.worker.js";
@ -31,7 +27,6 @@ import { executeWorkspaceJournalCommand } from "../gateway/worker-environments/p
import { isWorkerEnvironmentCommand } from "../gateway/worker-environments/store-worker-contract.js";
import { executeWorkerEnvironmentCommand } from "../gateway/worker-environments/store.worker.js";
import * as deviceAuth from "../infra/device-auth-store.kernel.js";
import { commitExecAuthorizationsInWorker } from "../infra/exec-approvals-authorization.worker.js";
import { createSqliteAuditRecordKernel } from "../infra/sqlite-audit-record.kernel.js";
import {
readStableSqliteFileGeneration,
@ -106,15 +101,6 @@ export function executeSharedStateCommand(
if (stateWorkerRegistry.has(command)) {
return stateWorkerRegistry.execute(command, { open, stateOptions });
}
if (command.type === "execApprovals.commitAuthorizations" || isOperatorApprovalCommand(command)) {
const databaseOptions = {
database: open(),
...stateOptions(),
};
return command.type === "execApprovals.commitAuthorizations"
? commitExecAuthorizationsInWorker(command.input, databaseOptions)
: executeOperatorApprovalCommand(command, databaseOptions);
}
if (isWorkerInferenceStoreCommand(command)) {
return executeWorkerInferenceStoreCommand(command, open());
}

View file

@ -82,6 +82,8 @@ const contractsConfig = "test/vitest/vitest.contracts-plugin.config.ts";
const channelsConfig = "test/vitest/vitest.channels.config.ts";
const codeModeWorker = "src/agents/code-mode.import-boundary.test.ts";
const agentsCoreConfig = agentVitestProjectOwners.core.config;
const discordCapture = "test/e2e/gateway-transcripts-discord-capture.e2e.test.ts";
const e2eConfig = "test/vitest/vitest.e2e.config.ts";
it.for([
{ name: "worker", args: [coreWorker], prepare: true },
@ -163,6 +165,7 @@ it.runIf(process.platform !== "win32").for(
: [
"ready",
"code-mode",
"capture",
"failure",
"cancel",
"excluded",
@ -185,12 +188,16 @@ it.runIf(process.platform !== "win32").for(
? packageContract
: mode === "code-mode"
? codeModeWorker
: coreWorker;
: mode === "capture"
? discordCapture
: coreWorker;
const selectedConfig = route.startsWith("contracts-")
? contractsConfig
: mode === "code-mode"
? agentsCoreConfig
: infraConfig;
: mode === "capture"
? e2eConfig
: infraConfig;
const { node } = workerArtifacts.createFixtureCommands();
const directory = workerArtifacts.fixtureDirectory();
const compiled = path.join(directory, "compiled.jsonl");
@ -301,6 +308,7 @@ syncFixtureBuiltinExports();
...process.env,
// Each nested invocation owns its selection, independently of the outer tooling shard.
OPENCLAW_VITEST_INCLUDE_FILE: includeFile,
OPENCLAW_E2E_USE_PREBUILT_DIST: "1",
...fixturePreloadEnv(preload, "node"),
});
expect(result.code, result.stdout + result.stderr).toBe(
@ -310,7 +318,8 @@ syncFixtureBuiltinExports();
mode === "ready" ||
mode === "include-worker" ||
mode === "channels" ||
mode === "code-mode";
mode === "code-mode" ||
mode === "capture";
const prepared = ready || mode === "failure" || mode === "cancel";
expect(fs.existsSync(compilerReceipt)).toBe(prepared);
if (mode === "failure" || mode === "cancel") {