fix(sqlite): validate agent state before schema setup (#105179)

This commit is contained in:
Vincent Koc
2026-07-12 16:57:18 +08:00
committed by GitHub
parent f84b6f6091
commit cf2af03d02
2 changed files with 109 additions and 0 deletions

View File

@@ -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(

View File

@@ -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,
});