mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(cron): preserve authority across downgrades
This commit is contained in:
@@ -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,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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -401,6 +401,7 @@ export interface CronJobRuntimeAuthorities {
|
||||
job_id: string;
|
||||
recovery_required: number;
|
||||
store_key: string;
|
||||
tool_bindings_json: string | null;
|
||||
}
|
||||
|
||||
export interface CronJobScratch {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user