mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
72f5840c11
commit
1b33b99c40
36 changed files with 578 additions and 428 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { sha256HexPrefixCore } from "./crypto-digest.js";
|
||||
// Owns durable approval matching and allow-always persistence.
|
||||
import {
|
||||
buildExecApprovalPolicyRuleKey,
|
||||
canonicalizeExecApprovalPolicyRules,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,3 +18,8 @@ export type ExecAllowlistEntry = {
|
|||
lastUsedCommand?: string;
|
||||
lastResolvedPath?: string;
|
||||
};
|
||||
|
||||
export type AllowAlwaysPattern = {
|
||||
pattern: string;
|
||||
argPattern?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue