diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index 5646a37f6e70..9707b09acd98 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -199,7 +199,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { const canResolveAnyScheduledCreatorAuthority = canResolveScheduledConfiguredMcpCreatorAuthority || requiresScheduledCodexAppAuthority || - (nativeToolSurfaceEnabled === true && sandbox?.enabled !== true); + (nativeToolSurfaceEnabled && sandbox?.enabled !== true); let toolBridge: ReturnType | undefined; let creatorAuthorityPromise: | Promise<{ diff --git a/src/claws/read-only-state-compat.test.ts b/src/claws/read-only-state-compat.test.ts index 6beb12665fd0..593cf79c261e 100644 --- a/src/claws/read-only-state-compat.test.ts +++ b/src/claws/read-only-state-compat.test.ts @@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, @@ -42,6 +43,9 @@ function createBaseShapeState(params: { for (const column of OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns ?? []) { const [table, name] = column.split("."); + if (!table || !tableExists(database.db, table)) { + continue; + } database.db.exec(`ALTER TABLE ${table} DROP COLUMN ${name};`); } closeOpenClawStateDatabaseForTest(); diff --git a/src/cron/runtime-authority.ts b/src/cron/runtime-authority.ts index 52b27fe44bf1..c279e853e8bc 100644 --- a/src/cron/runtime-authority.ts +++ b/src/cron/runtime-authority.ts @@ -12,6 +12,12 @@ const CRON_RUNTIME_AUTHORITY_KEYS = new Set([ "payload", "toolBindings", ]); +const CRON_PERSISTED_RUNTIME_AUTHORITY_KEYS = new Set([ + "version", + "runtimeId", + "namespace", + "payload", +]); const CRON_RUNTIME_AUTHORITY_MAX_TOOL_BINDINGS = 16; type JsonPrimitive = string | number | boolean | null; @@ -119,7 +125,9 @@ function deepFreezeJson(value: JsonValue): JsonValue { return value; } -function normalizeToolBindings(value: unknown): readonly CronScheduledToolBinding[] | undefined { +export function normalizeCronScheduledToolBindings( + value: unknown, +): readonly CronScheduledToolBinding[] | undefined { if (value === undefined) { return undefined; } @@ -183,7 +191,7 @@ export function normalizeCronRuntimeAuthority(value: unknown): CronRuntimeAuthor const runtimeId = normalizeAuthorityId(value.runtimeId); const namespace = normalizeAuthorityId(value.namespace); const payload = cloneJsonObject(value.payload); - const toolBindings = normalizeToolBindings(value.toolBindings); + const toolBindings = normalizeCronScheduledToolBindings(value.toolBindings); if (!runtimeId || !namespace || !payload || (value.toolBindings !== undefined && !toolBindings)) { return undefined; } @@ -200,6 +208,37 @@ export function normalizeCronRuntimeAuthority(value: unknown): CronRuntimeAuthor return Object.freeze(normalized); } +type CronPersistedRuntimeAuthority = Omit; + +/** Reads the downgrade-stable authority shape understood by older binaries. */ +export function normalizeCronPersistedRuntimeAuthority( + value: unknown, +): CronPersistedRuntimeAuthority | undefined { + if ( + !isRecord(value) || + Object.keys(value).some((key) => !CRON_PERSISTED_RUNTIME_AUTHORITY_KEYS.has(key)) + ) { + return undefined; + } + return normalizeCronRuntimeAuthority(value); +} + +/** Separates newer binding metadata from the older authority JSON envelope. */ +export function serializeCronRuntimeAuthority( + value: CronRuntimeAuthority, +): CronPersistedRuntimeAuthority | undefined { + const normalized = normalizeCronRuntimeAuthority(value); + if (!normalized) { + return undefined; + } + return normalizeCronPersistedRuntimeAuthority({ + version: normalized.version, + runtimeId: normalized.runtimeId, + namespace: normalized.namespace, + payload: normalized.payload, + }); +} + export function cloneCronRuntimeAuthority( value: CronRuntimeAuthority, ): CronRuntimeAuthority | undefined { diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 82adee8915a3..897753a652ee 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -752,6 +752,45 @@ describe("cron store", () => { const row = database.prepare("SELECT job_json FROM cron_jobs WHERE job_id = ?").get(job.id) as { job_json: string; }; + const authorityRow = database + .prepare( + "SELECT store_key, authority_json, authority_input_fingerprint, recovery_required, tool_bindings_json FROM cron_job_runtime_authorities WHERE job_id = ?", + ) + .get(job.id) as { + store_key: string; + authority_json: string; + authority_input_fingerprint: string; + recovery_required: number; + tool_bindings_json: string; + }; + expect(JSON.parse(authorityRow.authority_json)).toEqual({ + version: 1, + runtimeId: "codex", + namespace: "codex.apps", + payload: { apps: [{ id: "calendar" }] }, + }); + database + .prepare( + `INSERT INTO cron_job_runtime_authorities + (store_key, job_id, authority_json, authority_input_fingerprint, recovery_required) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (store_key, job_id) DO UPDATE SET + authority_json = excluded.authority_json, + authority_input_fingerprint = excluded.authority_input_fingerprint, + recovery_required = excluded.recovery_required`, + ) + .run( + authorityRow.store_key, + job.id, + authorityRow.authority_json, + authorityRow.authority_input_fingerprint, + authorityRow.recovery_required, + ); + expect( + database + .prepare("SELECT tool_bindings_json FROM cron_job_runtime_authorities WHERE job_id = ?") + .get(job.id), + ).toEqual({ tool_bindings_json: authorityRow.tool_bindings_json }); const downgradedJob = JSON.parse(row.job_json) as Record; delete downgradedJob.runtimeAuthority; delete downgradedJob.runtimeAuthorityRecoveryRequired; @@ -766,6 +805,35 @@ describe("cron store", () => { expect(reloaded?.runtimeAuthorityRecoveryRequired).toBeUndefined(); }); + it("rejects stale bindings after an older writer replaces authority_json", async () => { + const { storePath } = await makeStorePath(); + const authorityStore = makeAuthorityStore("downgrade-stale-bindings"); + const job = expectDefined(authorityStore.jobs[0], "authority job test invariant"); + await saveCronStore(storePath, authorityStore); + + const database = openOpenClawStateDatabase().db; + database + .prepare("UPDATE cron_job_runtime_authorities SET authority_json = ? WHERE job_id = ?") + .run( + JSON.stringify({ + version: 1, + runtimeId: "codex", + namespace: "codex.apps", + payload: { apps: [{ id: "mail" }] }, + }), + job.id, + ); + + const reloaded = (await loadCronStore(storePath)).jobs[0]; + expect(reloaded?.runtimeAuthority).toBeUndefined(); + expect(reloaded?.runtimeAuthorityRecoveryRequired).toBe(true); + expect( + database + .prepare("SELECT recovery_required FROM cron_job_runtime_authorities WHERE job_id = ?") + .get(job.id), + ).toEqual({ recovery_required: 1 }); + }); + it("stores authority outside job_json and restores it after reopen", async () => { const { storePath } = await makeStorePath(); const authorityStore = makeAuthorityStore("authority-companion-row"); @@ -782,14 +850,24 @@ describe("cron store", () => { expect(parentJson).not.toHaveProperty("runtimeAuthorityRecoveryRequired"); const child = database .prepare( - "SELECT authority_json, authority_input_fingerprint, recovery_required FROM cron_job_runtime_authorities WHERE job_id = ?", + "SELECT authority_json, authority_input_fingerprint, recovery_required, tool_bindings_json FROM cron_job_runtime_authorities WHERE job_id = ?", ) .get(job.id) as { authority_json: string; authority_input_fingerprint: string; recovery_required: number; + tool_bindings_json: string; }; - expect(JSON.parse(child.authority_json)).toEqual(job.runtimeAuthority); + const { toolBindings: expectedToolBindings, ...expectedPersistedAuthority } = expectDefined( + job.runtimeAuthority, + "runtime authority test invariant", + ); + expect(JSON.parse(child.authority_json)).toEqual(expectedPersistedAuthority); + expect(JSON.parse(child.tool_bindings_json)).toEqual({ + version: 1, + authoritySha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + bindings: expectedToolBindings, + }); expect(child.authority_input_fingerprint).toMatch(/^v1:[a-f0-9]{64}$/u); expect(child.recovery_required).toBe(0); diff --git a/src/cron/store/runtime-authority-store.ts b/src/cron/store/runtime-authority-store.ts index ede3072aaa6e..d17065dd2961 100644 --- a/src/cron/store/runtime-authority-store.ts +++ b/src/cron/store/runtime-authority-store.ts @@ -4,21 +4,28 @@ import type { DatabaseSync } from "node:sqlite"; import { safeParseJson } from "@openclaw/normalization-core"; import type { Selectable } from "kysely"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; -import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js"; +import { ensureColumn, tableExists } from "../../state/openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; -import { normalizeCronRuntimeAuthority } from "../runtime-authority.js"; +import { + normalizeCronPersistedRuntimeAuthority, + normalizeCronRuntimeAuthority, + normalizeCronScheduledToolBindings, + serializeCronRuntimeAuthority, +} from "../runtime-authority.js"; import { normalizeCronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import { cronJobUsesToolRuntime } from "../tools-allow.js"; import type { CronStoredJob, CronToolsAllowProvenance } from "../types.js"; const CRON_RUNTIME_AUTHORITY_TABLE = "cron_job_runtime_authorities"; const CRON_RUNTIME_AUTHORITY_FINGERPRINT_VERSION = 1; +const CRON_TOOL_BINDINGS_STORAGE_VERSION = 1; const CRON_RUNTIME_AUTHORITY_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS cron_job_runtime_authorities ( store_key TEXT NOT NULL, job_id TEXT NOT NULL, authority_json TEXT, + tool_bindings_json TEXT, authority_input_fingerprint TEXT, recovery_required INTEGER NOT NULL, PRIMARY KEY (store_key, job_id), @@ -78,10 +85,39 @@ function cronRuntimeAuthorityInputFingerprint(job: CronStoredJob): string { .digest("hex")}`; } +function authorityJsonSha256(authorityJson: string): string { + return createHash("sha256").update(authorityJson, "utf8").digest("hex"); +} + +function parseStoredToolBindings( + value: unknown, + authorityJson: string, +): ReturnType { + // Unshipped raw-array prototypes carry no authority association, so accepting + // them after a downgrade could restore stale exec authority. + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + !("version" in value) || + value.version !== CRON_TOOL_BINDINGS_STORAGE_VERSION || + !("authoritySha256" in value) || + value.authoritySha256 !== authorityJsonSha256(authorityJson) || + !("bindings" in value) || + Object.keys(value).some( + (key) => key !== "version" && key !== "authoritySha256" && key !== "bindings", + ) + ) { + return undefined; + } + return normalizeCronScheduledToolBindings(value.bindings); +} + /** Creates the additive table only when authority state is first persisted. */ function ensureCronRuntimeAuthorityTable(db: DatabaseSync): void { // sqlite-allow-raw -- feature-local additive schema DDL; data access uses Kysely. db.exec(CRON_RUNTIME_AUTHORITY_SCHEMA_SQL); + ensureColumn(db, CRON_RUNTIME_AUTHORITY_TABLE, "tool_bindings_json TEXT"); } function loadCronRuntimeAuthorityRows( @@ -110,7 +146,21 @@ function applyCronRuntimeAuthorityRow( job.runtimeAuthorityRecoveryRequired = true; return "ok"; } - const authority = normalizeCronRuntimeAuthority(safeParseJson(row.authority_json ?? "")); + const persistedAuthority = normalizeCronPersistedRuntimeAuthority( + safeParseJson(row.authority_json ?? ""), + ); + const authorityJson = row.authority_json ?? ""; + const toolBindings = + row.tool_bindings_json == null + ? undefined + : parseStoredToolBindings(safeParseJson(row.tool_bindings_json), authorityJson); + const authority = + persistedAuthority && (row.tool_bindings_json == null || toolBindings) + ? normalizeCronRuntimeAuthority({ + ...persistedAuthority, + ...(toolBindings ? { toolBindings } : {}), + }) + : undefined; if (!authority || row.authority_input_fingerprint !== cronRuntimeAuthorityInputFingerprint(job)) { job.runtimeAuthorityRecoveryRequired = true; return "repair"; @@ -148,12 +198,14 @@ function writeRecoveryRow(db: DatabaseSync, storeKey: string, jobId: string): vo store_key: storeKey, job_id: jobId, authority_json: null, + tool_bindings_json: null, authority_input_fingerprint: null, recovery_required: 1, }) .onConflict((conflict) => conflict.columns(["store_key", "job_id"]).doUpdateSet({ authority_json: null, + tool_bindings_json: null, authority_input_fingerprint: null, recovery_required: 1, }), @@ -211,7 +263,19 @@ export function replaceCronRuntimeAuthorityRows(params: { } const authority = normalizeCronRuntimeAuthority(job.runtimeAuthority); if (authority) { - const authorityJson = JSON.stringify(authority); + const persistedAuthority = serializeCronRuntimeAuthority(authority); + if (!persistedAuthority) { + writeRecoveryRow(params.db, params.storeKey, job.id); + continue; + } + const authorityJson = JSON.stringify(persistedAuthority); + const toolBindingsJson = authority.toolBindings + ? JSON.stringify({ + version: CRON_TOOL_BINDINGS_STORAGE_VERSION, + authoritySha256: authorityJsonSha256(authorityJson), + bindings: authority.toolBindings, + }) + : null; const authorityInputFingerprint = cronRuntimeAuthorityInputFingerprint(job); executeSqliteQuerySync( params.db, @@ -221,12 +285,14 @@ export function replaceCronRuntimeAuthorityRows(params: { store_key: params.storeKey, job_id: job.id, authority_json: authorityJson, + tool_bindings_json: toolBindingsJson, authority_input_fingerprint: authorityInputFingerprint, recovery_required: 0, }) .onConflict((conflict) => conflict.columns(["store_key", "job_id"]).doUpdateSet({ authority_json: authorityJson, + tool_bindings_json: toolBindingsJson, authority_input_fingerprint: authorityInputFingerprint, recovery_required: 0, }), diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index 8cb60361e0fc..96eadeb5caa2 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -185,6 +185,7 @@ describe("OpenClaw database maintenance schema validation", () => { "device_bootstrap_tokens.setup_id TEXT", "session_groups.cwd TEXT", "session_groups.worktree INTEGER", + "cron_job_runtime_authorities.tool_bindings_json TEXT", "installed_plugin_index.workspace_dir TEXT", "secret_store_entries.allowed_hosts TEXT", "skill_workshop_proposals.claim_released_time INTEGER", diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts index 3e04b63e0564..12aa2e2a05db 100644 --- a/src/state/openclaw-state-db-additive-columns.ts +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -37,6 +37,11 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [ { columnName: "setup_id", dataType: "TEXT", tableName: "device_bootstrap_tokens" }, { columnName: "cwd", dataType: "TEXT", tableName: "session_groups" }, { columnName: "worktree", dataType: "INTEGER", tableName: "session_groups" }, + { + columnName: "tool_bindings_json", + dataType: "TEXT", + tableName: "cron_job_runtime_authorities", + }, { columnName: "workspace_dir", dataType: "TEXT", tableName: "installed_plugin_index" }, { columnName: "allowed_hosts", dataType: "TEXT", tableName: "secret_store_entries" }, { @@ -53,6 +58,7 @@ function isFirstUseAdditiveStateColumn({ return ( (tableName === "device_bootstrap_tokens" && columnName === "setup_id") || (tableName === "skill_workshop_proposals" && columnName === "claim_released_time") || + (tableName === "cron_job_runtime_authorities" && columnName === "tool_bindings_json") || (tableName === "worker_session_placement_moves" && (columnName === "abandon_source" || columnName === "target_machine_class")) || (tableName === "session_groups" && (columnName === "cwd" || columnName === "worktree")) diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 4ef4a813f841..3708d6412431 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -401,6 +401,7 @@ export interface CronJobRuntimeAuthorities { job_id: string; recovery_required: number; store_key: string; + tool_bindings_json: string | null; } export interface CronJobScratch { diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 589f217658fa..8d701ec2baef 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1623,6 +1623,7 @@ CREATE TABLE IF NOT EXISTS cron_job_runtime_authorities ( store_key TEXT NOT NULL, job_id TEXT NOT NULL, authority_json TEXT, + tool_bindings_json TEXT, authority_input_fingerprint TEXT, recovery_required INTEGER NOT NULL, PRIMARY KEY (store_key, job_id),