fix(cron): preserve authority across downgrades

This commit is contained in:
Josh Lehman
2026-08-21 11:23:09 -07:00
parent f74e22f15c
commit 5bbf4d1aed
9 changed files with 205 additions and 9 deletions
@@ -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<typeof createCodexDynamicToolBridge> | undefined;
let creatorAuthorityPromise:
| Promise<{
+4
View File
@@ -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();
+41 -2
View File
@@ -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<CronRuntimeAuthority, "toolBindings">;
/** 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 {
+80 -2
View File
@@ -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<string, unknown>;
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);
+70 -4
View File
@@ -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<typeof normalizeCronScheduledToolBindings> {
// 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,
}),
@@ -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",
@@ -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"))
+1
View File
@@ -401,6 +401,7 @@ export interface CronJobRuntimeAuthorities {
job_id: string;
recovery_required: number;
store_key: string;
tool_bindings_json: string | null;
}
export interface CronJobScratch {
+1
View File
@@ -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),