mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user