fix(sqlite): avoid mutating invalid databases during repair (#113592)

* fix(sqlite): keep hostile schema repair atomic

* fix(sqlite): restore legacy schema ownership metadata

* fix(sqlite): validate complete maintenance repairs
This commit is contained in:
Vincent Koc
2026-07-25 17:50:19 +08:00
committed by GitHub
parent 55d66fbf98
commit 84fb329895
8 changed files with 173 additions and 22 deletions
+3
View File
@@ -33,6 +33,8 @@ export function repairCanonicalSqliteIndexes(
* canonical index. No other repair failure is deferred.
*/
allowMissingColumns?: boolean;
/** Keep index repair atomic with the caller's whole-schema validation. */
validateAfterRepair?: () => void;
verifyPhysicalIntegrity?: boolean;
} = {},
): string[] {
@@ -107,6 +109,7 @@ export function repairCanonicalSqliteIndexes(
assertSqliteTableIntegrity(db, databaseLabel, tableName);
}
assertSqliteIntegrity(db, databaseLabel);
options.validateAfterRepair?.();
db.exec(`RELEASE SAVEPOINT ${savepoint};`);
} catch (error) {
try {
+10
View File
@@ -1977,6 +1977,16 @@ describe("state migrations", () => {
expect(db.prepare("PRAGMA user_version").get()).toEqual({
user_version: OPENCLAW_STATE_SCHEMA_VERSION,
});
expect(
db
.prepare(
"SELECT role, schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1",
)
.get(),
).toEqual({
role: "global",
schema_version: OPENCLAW_STATE_SCHEMA_VERSION,
});
} finally {
db.close();
}
+13 -1
View File
@@ -99,7 +99,19 @@ export function migrateOpenClawAgentDatabaseForMaintenance(options: {
return;
}
if (hasCurrentVersion) {
repairCanonicalSqliteIndexes(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL);
repairCanonicalSqliteIndexes(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL, {
// The maintenance contract is the runtime owner/schema contract plus
// an exact user_version gate, so table drift rolls this savepoint back.
validateAfterRepair: () =>
assertOpenClawAgentDatabaseForMaintenance(database, {
agentId,
pathname: options.pathname,
}),
});
assertOpenClawAgentDatabaseForMaintenance(database, {
agentId,
pathname: options.pathname,
});
return;
}
ensureOpenClawAgentDatabaseSchema(database, {
+2
View File
@@ -473,6 +473,8 @@ export function assertAgentDatabaseIntegrityBeforeMutation(
userVersion === OPENCLAW_AGENT_SCHEMA_VERSION
? repairCanonicalSqliteIndexes(database, pathname, OPENCLAW_AGENT_SCHEMA_SQL, {
allowMissingColumns: true,
validateAfterRepair: () =>
assertOpenClawAgentCurrentRuntimeSchema(database, { agentId, pathname }),
})
: [];
if (rebuiltIndexes.length === 0) {
+28 -2
View File
@@ -43,6 +43,7 @@ import {
ensureOpenClawAgentDatabaseSchema,
inspectOpenClawAgentDatabaseOwner,
listOpenClawRegisteredAgentDatabases,
migrateOpenClawAgentDatabaseForMaintenance,
OPENCLAW_AGENT_SCHEMA_VERSION,
openOpenClawAgentDatabase,
resolveOpenClawAgentSqlitePath,
@@ -2847,6 +2848,12 @@ describe("openclaw agent database", () => {
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/missing table auth_profile_store/iu,
);
expect(() =>
migrateOpenClawAgentDatabaseForMaintenance({
agentId: "worker-1",
pathname: databasePath,
}),
).toThrow(/missing table auth_profile_store/iu);
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
@@ -2943,8 +2950,7 @@ describe("openclaw agent database", () => {
PRIMARY KEY (scope, key)
) STRICT;
CREATE INDEX idx_agent_cache_expiry
ON cache_entries(scope, expires_at, key)
WHERE expires_at IS NOT NULL;
ON cache_entries(key);
CREATE INDEX idx_agent_cache_updated
ON cache_entries(scope, updated_at DESC, key);
`);
@@ -2953,6 +2959,26 @@ describe("openclaw agent database", () => {
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/unexpected unique index on cache_entries/iu,
);
expect(() =>
migrateOpenClawAgentDatabaseForMaintenance({
agentId: "worker-1",
pathname: databasePath,
}),
).toThrow(/unexpected unique index on cache_entries/iu);
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
after
.prepare(
"SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = 'idx_agent_cache_expiry'",
)
.get(),
).toEqual({
sql: "CREATE INDEX idx_agent_cache_expiry\n ON cache_entries(key)",
});
} finally {
after.close();
}
});
it("rejects primary-key collation drift in a current-schema table", () => {
+20 -2
View File
@@ -107,7 +107,10 @@ export function repairLegacyGatewayRestartHandoffsForStrictMigration(db: Databas
`);
}
export function markCurrentStateSchemaVersion(db: DatabaseSync): void {
export function markCurrentStateSchemaVersion(
db: DatabaseSync,
options: { createMetadataIfMissing?: boolean } = {},
): void {
// Pre-v2 databases can legitimately predate the audit table. Leave their
// version untouched so normal open can create the complete v2 schema first.
if (!tableExists(db, "audit_events")) {
@@ -120,9 +123,24 @@ export function markCurrentStateSchemaVersion(db: DatabaseSync): void {
tableHasColumn(db, "schema_meta", column),
)
) {
const now = Date.now();
if (options.createMetadataIfMissing) {
// Recognized pre-metadata schemas may acquire the global owner row during
// doctor migration. Conflicting existing ownership is preserved so the
// final maintenance assertion rejects and rolls back the repair.
db.prepare(
`INSERT INTO schema_meta (
meta_key, role, schema_version, agent_id, app_version, created_at, updated_at
) VALUES ('primary', 'global', ?, NULL, NULL, ?, ?)
ON CONFLICT(meta_key) DO UPDATE SET
schema_version = excluded.schema_version,
updated_at = excluded.updated_at`,
).run(OPENCLAW_STATE_SCHEMA_VERSION, now, now);
return;
}
db.prepare(
"UPDATE schema_meta SET schema_version = ?, updated_at = ? WHERE meta_key = 'primary'",
).run(OPENCLAW_STATE_SCHEMA_VERSION, Date.now());
).run(OPENCLAW_STATE_SCHEMA_VERSION, now);
}
}
+66 -4
View File
@@ -750,7 +750,11 @@ function runHotRollbackJournalRecoveryProbe(params: { moduleUrl: string; rootDir
};
}
function expectNoncanonicalAuditSchemaRejected(stateDir: string, databasePath: string): void {
function expectNoncanonicalAuditSchemaRejected(
stateDir: string,
databasePath: string,
doctorWarning = "cannot be repaired automatically",
): void {
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([
{ kind: "audit-events-v2", path: databasePath },
@@ -758,7 +762,7 @@ function expectNoncanonicalAuditSchemaRejected(stateDir: string, databasePath: s
expect(() => openOpenClawStateDatabase(options)).toThrow(/noncanonical audit event schema/);
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({
changes: [],
warnings: [expect.stringContaining("cannot be repaired automatically")],
warnings: [expect.stringContaining(doctorWarning)],
});
}
@@ -1810,6 +1814,10 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
drifted.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu);
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({
changes: [],
warnings: [expect.stringContaining("missing table auth_profile_stores")],
});
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
@@ -1872,13 +1880,31 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
PRIMARY KEY (scope, event_key)
) STRICT;
CREATE INDEX idx_diagnostic_events_scope_sequence
ON diagnostic_events(scope, sequence, event_key);
ON diagnostic_events(event_key);
`);
drifted.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(
/unexpected unique index on diagnostic_events/iu,
);
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({
changes: [],
warnings: [expect.stringContaining("unexpected unique index on diagnostic_events")],
});
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
after
.prepare(
"SELECT sql FROM sqlite_schema WHERE type = 'index' AND name = 'idx_diagnostic_events_scope_sequence'",
)
.get(),
).toEqual({
sql: "CREATE INDEX idx_diagnostic_events_scope_sequence\n ON diagnostic_events(event_key)",
});
} finally {
after.close();
}
});
it("rejects primary-key collation drift in a current-schema table", () => {
@@ -2093,6 +2119,42 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
}
});
it("does not claim a legacy audit database with conflicting ownership", () => {
const stateDir = createTempStateDir();
const databasePath = createLegacyAuditStateDatabase(stateDir);
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec("UPDATE schema_meta SET role = 'agent', agent_id = 'worker-1';");
legacy.close();
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({
changes: [],
warnings: [expect.stringContaining("schema role agent; expected global")],
});
const preserved = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(readSqliteNumberPragma(preserved, "user_version")).toBe(1);
expect(
preserved
.prepare(
"SELECT role, schema_version, agent_id FROM schema_meta WHERE meta_key = 'primary'",
)
.get(),
).toEqual({ role: "agent", schema_version: 1, agent_id: "worker-1" });
expect(
preserved
.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'auth_profile_stores'",
)
.get(),
).toBeUndefined();
} finally {
preserved.close();
}
});
it("refuses an audit sequence high-water mark outside the supported cursor range", () => {
const stateDir = createTempStateDir();
const databasePath = createLegacyAuditStateDatabase(stateDir);
@@ -2264,7 +2326,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
malformed.exec("DROP TABLE audit_events");
malformed.close();
expectNoncanonicalAuditSchemaRejected(stateDir, databasePath);
expectNoncanonicalAuditSchemaRejected(stateDir, databasePath, "missing table audit_events");
const preserved = new DatabaseSync(databasePath, { readOnly: true });
try {
+31 -13
View File
@@ -17,6 +17,7 @@ import {
type SqliteIntegrityConfirmation,
} from "../infra/sqlite-integrity.js";
import { prepareSqliteReadOnlyLocation } from "../infra/sqlite-readonly-location.js";
import { assertSqliteSchemaTablesPresent } from "../infra/sqlite-schema-contract.js";
import { migrateSqliteSchemaToStrictInTransaction } from "../infra/sqlite-strict.js";
import { createSqliteTerminalOpenLatch } from "../infra/sqlite-terminal-open-latch.js";
import {
@@ -148,22 +149,25 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
const rebuiltIndexNames = new Set<string>();
try {
assertSupportedSchemaVersion(db, pathname);
if (readSqliteUserVersion(db) === OPENCLAW_STATE_SCHEMA_VERSION) {
for (const name of repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
allowMissingColumns: true,
})) {
rebuiltIndexNames.add(name);
}
}
if (rebuiltIndexNames.size === 0) {
assertSqliteIntegrity(db, pathname);
}
db.exec("PRAGMA foreign_keys = OFF;");
const changes = runSqliteImmediateTransactionSync(
db,
() => {
const applied: string[] = [];
const previousVersion = readSqliteUserVersion(db);
if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
for (const name of repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
allowMissingColumns: true,
})) {
rebuiltIndexNames.add(name);
}
// Current-schema doctor repair may normalize recognized columns or
// table options, but it must never recreate a missing table empty.
assertSqliteSchemaTablesPresent(db, pathname, OPENCLAW_STATE_SCHEMA_SQL);
}
if (rebuiltIndexNames.size === 0) {
assertSqliteIntegrity(db, pathname);
}
dropLegacyStateTables(db);
if (repairAgentDatabasesCompositePrimaryKey(db)) {
applied.push(`Migrated shared state agent database registry primary key → agent_id,path`);
@@ -205,7 +209,12 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
rebuiltIndexNames.add(name);
}
}
markCurrentStateSchemaVersion(db);
markCurrentStateSchemaVersion(db, {
createMetadataIfMissing: previousVersion < OPENCLAW_STATE_SCHEMA_VERSION,
});
if (readSqliteUserVersion(db) === OPENCLAW_STATE_SCHEMA_VERSION) {
assertCurrentStateRuntimeSchema(db, pathname);
}
if (rebuiltIndexNames.size > 0) {
applied.push(`Rebuilt canonical shared-state SQLite indexes (${rebuiltIndexNames.size})`);
}
@@ -262,8 +271,7 @@ function ensureSchema(db: DatabaseSync, pathname: string): void {
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
verifyPhysicalIntegrity: false,
});
assertCanonicalStateSchemaShape(db, pathname);
assertOpenClawStateDatabaseForMaintenance(db, { pathname });
assertCurrentStateRuntimeSchema(db, pathname);
} else if (previousVersion === 5) {
assertOpenClawStateDatabaseV5ForMigration(db, { pathname });
}
@@ -394,6 +402,12 @@ export async function openExistingOpenClawStateDatabaseReadOnly(
},
};
}
function assertCurrentStateRuntimeSchema(database: DatabaseSync, pathname: string): void {
assertCanonicalStateSchemaShape(database, pathname);
assertOpenClawStateDatabaseForMaintenance(database, { pathname });
}
function assertStateDatabaseIntegrityBeforeMutation(
database: DatabaseSync,
pathname: string,
@@ -417,12 +431,16 @@ function assertStateDatabaseIntegrityBeforeMutation(
userVersion === OPENCLAW_STATE_SCHEMA_VERSION
? repairCanonicalSqliteIndexes(database, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
allowMissingColumns: true,
validateAfterRepair: () => assertCurrentStateRuntimeSchema(database, pathname),
})
: [];
if (rebuiltIndexes.length === 0) {
// Every physical open proves the full file before schema mutation or exposure.
assertSqliteIntegrity(database, pathname);
}
if (userVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
assertCurrentStateRuntimeSchema(database, pathname);
}
}
/** Open or return a cached shared state database after schema and migration checks. */