From cf2af03d02b8cb79899537761e56b63efa76fde3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Jul 2026 16:57:18 +0800 Subject: [PATCH] fix(sqlite): validate agent state before schema setup (#105179) --- src/state/openclaw-agent-db.test.ts | 96 +++++++++++++++++++++++++++++ src/state/openclaw-agent-db.ts | 13 ++++ 2 files changed, 109 insertions(+) diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index 9ffc094895c9..a6f3555d9380 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -18,6 +18,7 @@ import { closeOpenClawAgentDatabaseByPath, closeOpenClawAgentDatabasesForTest, disposeOpenClawAgentDatabaseByPath, + ensureOpenClawAgentDatabaseSchema, inspectOpenClawAgentDatabaseOwner, listOpenClawRegisteredAgentDatabases, OPENCLAW_AGENT_SCHEMA_VERSION, @@ -156,6 +157,34 @@ function seedVersion1MemoryAgentDatabase( } } +function createUnsafeIndexDrift(databasePath: string): void { + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + try { + database.exec(` + CREATE TABLE unsafe_index_records ( + id INTEGER PRIMARY KEY, + indexed_value TEXT NOT NULL, + alternate_value TEXT NOT NULL + ); + CREATE INDEX unsafe_index_records_value ON unsafe_index_records(indexed_value); + INSERT INTO unsafe_index_records (indexed_value, alternate_value) + VALUES ('alpha', 'zeta'), ('beta', 'eta'), ('gamma', 'theta'); + `); + database.enableDefensive?.(false); + database.exec("PRAGMA writable_schema = ON;"); + database + .prepare( + "UPDATE sqlite_schema SET sql = 'CREATE INDEX unsafe_index_records_value ON unsafe_index_records(alternate_value)' WHERE name = 'unsafe_index_records_value'", + ) + .run(); + const schemaVersion = readSqliteNumberPragma(database, "schema_version"); + database.exec(`PRAGMA writable_schema = OFF; PRAGMA schema_version = ${schemaVersion + 1};`); + } finally { + database.close(); + } +} + type AgentSchemaOpenerResult = { agentId: string; ok: boolean; error?: string }; function launchAgentSchemaOpener(params: { @@ -1354,6 +1383,73 @@ describe("openclaw agent database", () => { "size", ]); }); + + it("rejects stale secondary indexes before writable initialization", () => { + const stateDir = createTempStateDir(); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const created = openOpenClawAgentDatabase({ agentId: "worker-1", env }); + const databasePath = created.path; + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + createUnsafeIndexDrift(databasePath); + + const { DatabaseSync } = requireNodeSqlite(); + const before = new DatabaseSync(databasePath, { readOnly: true }); + let metadataBefore: unknown; + try { + expect(before.prepare("PRAGMA quick_check").get()).toEqual({ quick_check: "ok" }); + expect(before.prepare("PRAGMA integrity_check").all()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrity_check: expect.stringMatching(/missing from index unsafe_index_records_value/), + }), + ]), + ); + metadataBefore = before + .prepare( + "SELECT schema_version, updated_at FROM schema_meta WHERE meta_key = 'primary' LIMIT 1", + ) + .get(); + } finally { + before.close(); + } + + expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow( + /integrity_check failed.*missing from index unsafe_index_records_value/iu, + ); + const independentlyManaged = new DatabaseSync(databasePath); + try { + expect(() => + ensureOpenClawAgentDatabaseSchema(independentlyManaged, { + agentId: "worker-1", + env, + }), + ).toThrow(/integrity_check failed.*missing from index unsafe_index_records_value/iu); + } finally { + independentlyManaged.close(); + } + + const after = new DatabaseSync(databasePath, { readOnly: true }); + try { + expect( + after + .prepare( + "SELECT schema_version, updated_at FROM schema_meta WHERE meta_key = 'primary' LIMIT 1", + ) + .get(), + ).toEqual(metadataBefore); + expect(after.prepare("PRAGMA integrity_check").all()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + integrity_check: expect.stringMatching(/missing from index unsafe_index_records_value/), + }), + ]), + ); + } finally { + after.close(); + } + }); + it("refuses to open newer per-agent schema versions", () => { const stateDir = createTempStateDir(); const databasePath = path.join( diff --git a/src/state/openclaw-agent-db.ts b/src/state/openclaw-agent-db.ts index 6404d6eb9f8a..1670875779d2 100644 --- a/src/state/openclaw-agent-db.ts +++ b/src/state/openclaw-agent-db.ts @@ -10,6 +10,7 @@ import { } from "../infra/kysely-sync.js"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; +import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js"; import { runSqliteImmediateTransactionSync, type SqliteTransactionOptions, @@ -608,6 +609,7 @@ export function ensureOpenClawAgentDatabaseSchema( const databaseOptions = { ...options, agentId }; const pathname = resolveOpenClawAgentSqlitePath(databaseOptions); ensureOpenClawAgentDatabasePermissions(pathname, databaseOptions); + assertAgentDatabaseIntegrityBeforeMutation(db, pathname); configureSqlitePreSchemaPragmas(db, { busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, }); @@ -803,6 +805,16 @@ export function inspectOpenClawAgentDatabaseOwner( } } +function assertAgentDatabaseIntegrityBeforeMutation( + database: DatabaseSync, + pathname: string, +): void { + database.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); + // Writable open permits interrupted journal recovery. Schema and connection + // setup must wait until full table/index consistency is proven afterward. + assertSqliteIntegrity(database, pathname); +} + /** Open or return a cached per-agent database after schema and owner validation. */ export function openOpenClawAgentDatabase( options: OpenClawAgentDatabaseOptions, @@ -833,6 +845,7 @@ export function openOpenClawAgentDatabase( const walMaintenance = (() => { let maintenance: SqliteWalMaintenance | undefined; try { + assertAgentDatabaseIntegrityBeforeMutation(db, pathname); configureSqlitePreSchemaPragmas(db, { busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS, });