UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

231 lines (229 loc) 11.5 kB
import "./src-vebZIeLe.js"; import { t as stableStringify } from "./stable-stringify-C8X7niaI.js"; import { a as getNodeSqliteKysely, i as executeSqliteQueryTakeFirstSync, r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js"; import { E as tableExists } from "./openclaw-state-db-cache-C7ljO0xP.js"; import { s as runOpenClawStateWriteTransaction } from "./openclaw-state-db-BRTnL-D8.js"; import { o as loadedCronStoreFromRows } from "./row-codec-Bv7_IRNO.js"; import { t as resolveCronJobConfigRevision } from "./config-revision-CExLxl38.js"; import { r as buildSystemRunApprovalEnvBinding } from "./system-run-approval-binding-D8O27dhf.js"; import { randomUUID } from "node:crypto"; //#region src/gateway/operator-approval-standing-grants.ts const STANDING_GRANT_TABLE = "operator_approval_standing_grants"; const STANDING_GRANT_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS operator_approval_standing_grants ( grant_id TEXT NOT NULL PRIMARY KEY CHECK (length(grant_id) > 0), minted_by_approval_id TEXT NOT NULL REFERENCES operator_approvals(approval_id) ON DELETE CASCADE, agent_id TEXT NOT NULL CHECK (length(agent_id) > 0), cron_job_id TEXT NOT NULL CHECK (length(cron_job_id) > 0), job_config_revision TEXT NOT NULL CHECK (length(job_config_revision) > 0), operation_binding TEXT NOT NULL CHECK (length(operation_binding) > 0), created_at_ms INTEGER NOT NULL, expires_at_ms INTEGER CHECK (expires_at_ms IS NULL OR expires_at_ms >= created_at_ms), revoked_at_ms INTEGER, revoked_by TEXT, last_used_at_ms INTEGER, use_count INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE INDEX IF NOT EXISTS idx_operator_approval_standing_grants_binding ON operator_approval_standing_grants(agent_id, cron_job_id, operation_binding, created_at_ms DESC); `; /** * Exact gateway-exec operation binding: trimmed command text, cwd, and the * env-override hash. Both mint (approval creation) and use (allowlist * evaluation) derive it from the same raw inputs, so matching is byte-exact — * the same semantics as systemRunBinding digest matching. */ function buildCronExecOperationBinding(params) { return stableStringify({ v: 1, command: params.command.trim(), cwd: params.cwd?.trim() || null, envHash: buildSystemRunApprovalEnvBinding(params.env).envHash }); } /** Parses a stored operation binding back into its display facts. */ function parseCronExecOperationBinding(binding) { try { const parsed = JSON.parse(binding); if (parsed.v !== 1 || typeof parsed.command !== "string") return null; return { command: parsed.command, cwd: typeof parsed.cwd === "string" ? parsed.cwd : null }; } catch { return null; } } function ensureStandingGrantSchema(db) { if (tableExists(db, STANDING_GRANT_TABLE)) { if (db.prepare(`PRAGMA table_info(${STANDING_GRANT_TABLE})`).all().some((column) => column.name === "expires_at_ms" && column.notnull === 1)) db.exec(`DROP TABLE ${STANDING_GRANT_TABLE};`); } db.exec(STANDING_GRANT_SCHEMA_SQL); } /** * Mints one standing grant inside the caller's open write transaction — the * same transaction that resolves the minting approval to allow-always. A * re-mint for the same (agent, job, binding) replaces prior grants. */ function mintCronStandingGrantLocked(database, params) { ensureStandingGrantSchema(database.db); const stateDb = getNodeSqliteKysely(database.db); executeSqliteQuerySync(database.db, stateDb.deleteFrom(STANDING_GRANT_TABLE).where("expires_at_ms", "is not", null).where("expires_at_ms", "<=", params.nowMs)); executeSqliteQuerySync(database.db, stateDb.deleteFrom(STANDING_GRANT_TABLE).where("agent_id", "=", params.agentId).where("cron_job_id", "=", params.cronJobId).where("operation_binding", "=", params.operationBinding)); executeSqliteQuerySync(database.db, stateDb.insertInto(STANDING_GRANT_TABLE).values({ grant_id: randomUUID(), minted_by_approval_id: params.approvalId, agent_id: params.agentId, cron_job_id: params.cronJobId, job_config_revision: params.jobConfigRevision, operation_binding: params.operationBinding, created_at_ms: params.nowMs, expires_at_ms: params.expiresAtMs, revoked_at_ms: null, revoked_by: null, last_used_at_ms: null, use_count: 0 })); } /** * Validates a standing grant without recording a use. Callers that skip the * prompt on this result must still call consumeCronStandingGrant at the final * execution boundary: authority is recorded only where the process spawns, so * a revocation or job edit during awaited pre-spawn work still fails closed. */ function validateCronStandingGrant(params) { return lookupCronStandingGrant(params, { recordUse: false }); } /** * Validates and consumes one standing grant for a cron-context exec. All * revalidation happens against authoritative rows inside one synchronous write * transaction: expiry, revocation, the cron job still existing with the same * config revision, and the minting approval row still holding allow-always. * Every non-consumed outcome means the caller falls through to prompting. */ function consumeCronStandingGrant(params) { return lookupCronStandingGrant(params, { recordUse: true }); } function lookupCronStandingGrant(params, opts) { return runOpenClawStateWriteTransaction((database) => { if (!tableExists(database.db, STANDING_GRANT_TABLE)) return { outcome: "no-grant" }; const nowMs = params.nowMs ?? Date.now(); const stateDb = getNodeSqliteKysely(database.db); const grant = executeSqliteQueryTakeFirstSync(database.db, stateDb.selectFrom(STANDING_GRANT_TABLE).selectAll().where("agent_id", "=", params.agentId).where("cron_job_id", "=", params.cronJobId).where("operation_binding", "=", params.operationBinding).orderBy("created_at_ms", "desc").orderBy("grant_id", "desc").limit(1)); if (!grant) return { outcome: "no-grant" }; if (grant.revoked_at_ms !== null) return { outcome: "revoked" }; if (grant.expires_at_ms !== null && grant.expires_at_ms <= nowMs) return { outcome: "expired" }; if (grant.job_config_revision !== params.jobConfigRevision) return { outcome: "job-revision-changed" }; const jobRows = executeSqliteQuerySync(database.db, stateDb.selectFrom("cron_jobs").selectAll().where("job_id", "=", params.cronJobId).limit(2)).rows; if (jobRows.length !== 1) return { outcome: "job-missing" }; const job = loadedCronStoreFromRows(jobRows).store.jobs.find((entry) => entry.id === params.cronJobId); if (!job) return { outcome: "job-missing" }; if (resolveCronJobConfigRevision(job) !== grant.job_config_revision) return { outcome: "job-revision-changed" }; const approvalRow = executeSqliteQueryTakeFirstSync(database.db, stateDb.selectFrom("operator_approvals").select(["status", "decision"]).where("approval_id", "=", grant.minted_by_approval_id)); if (!approvalRow) return { outcome: "approval-missing" }; if (approvalRow.status !== "allowed" || approvalRow.decision !== "allow-always") return { outcome: "approval-not-allow-always" }; if (!opts.recordUse) return { outcome: "consumed", grant: { grantId: grant.grant_id, mintedByApprovalId: grant.minted_by_approval_id, agentId: grant.agent_id, cronJobId: grant.cron_job_id, jobConfigRevision: grant.job_config_revision, operationBinding: grant.operation_binding, createdAtMs: grant.created_at_ms, expiresAtMs: grant.expires_at_ms, lastUsedAtMs: grant.last_used_at_ms, useCount: grant.use_count } }; const nextUseCount = grant.use_count + 1; if (executeSqliteQuerySync(database.db, stateDb.updateTable(STANDING_GRANT_TABLE).set({ last_used_at_ms: nowMs, use_count: nextUseCount }).where("grant_id", "=", grant.grant_id).where("revoked_at_ms", "is", null).where((eb) => eb.or([eb("expires_at_ms", "is", null), eb("expires_at_ms", ">", nowMs)]))).numAffectedRows !== 1n) return { outcome: "no-grant" }; return { outcome: "consumed", grant: { grantId: grant.grant_id, mintedByApprovalId: grant.minted_by_approval_id, agentId: grant.agent_id, cronJobId: grant.cron_job_id, jobConfigRevision: grant.job_config_revision, operationBinding: grant.operation_binding, createdAtMs: grant.created_at_ms, expiresAtMs: grant.expires_at_ms, lastUsedAtMs: nowMs, useCount: nextUseCount } }; }, params.databaseOptions); } /** * 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. */ function listCronStandingGrants(params = {}) { 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(database.db); return 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.map((row) => ({ grantId: row.grant_id, mintedByApprovalId: row.minted_by_approval_id, agentId: row.agent_id, cronJobId: row.cron_job_id, jobConfigRevision: row.job_config_revision, operationBinding: row.operation_binding, createdAtMs: row.created_at_ms, expiresAtMs: row.expires_at_ms, lastUsedAtMs: row.last_used_at_ms, useCount: row.use_count, cronJobName: row.cron_job_name ?? null, revokedAtMs: row.revoked_at_ms, revokedBy: row.revoked_by })); }, params.databaseOptions); } /** * 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. */ function revokeCronStandingGrant(params) { return runOpenClawStateWriteTransaction((database) => { if (!tableExists(database.db, STANDING_GRANT_TABLE)) return { outcome: "not-found" }; const nowMs = params.nowMs ?? Date.now(); const stateDb = getNodeSqliteKysely(database.db); const grant = executeSqliteQueryTakeFirstSync(database.db, stateDb.selectFrom(STANDING_GRANT_TABLE).selectAll().where("grant_id", "=", params.grantId)); if (!grant) return { outcome: "not-found" }; if (grant.revoked_at_ms !== null) return { outcome: "already-revoked" }; executeSqliteQuerySync(database.db, stateDb.updateTable(STANDING_GRANT_TABLE).set({ revoked_at_ms: nowMs, revoked_by: params.revokedBy }).where("grant_id", "=", params.grantId).where("revoked_at_ms", "is", null)); return { outcome: "revoked", grant: { grantId: grant.grant_id, mintedByApprovalId: grant.minted_by_approval_id, agentId: grant.agent_id, cronJobId: grant.cron_job_id, jobConfigRevision: grant.job_config_revision, operationBinding: grant.operation_binding, createdAtMs: grant.created_at_ms, expiresAtMs: grant.expires_at_ms, lastUsedAtMs: grant.last_used_at_ms, useCount: grant.use_count, cronJobName: null, revokedAtMs: nowMs, revokedBy: params.revokedBy } }; }, params.databaseOptions); } //#endregion export { parseCronExecOperationBinding as a, mintCronStandingGrantLocked as i, consumeCronStandingGrant as n, revokeCronStandingGrant as o, listCronStandingGrants as r, validateCronStandingGrant as s, buildCronExecOperationBinding as t };