From 1a595b97d6ae229858565b0c745b3fd4ab0663d1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 21 Jul 2026 16:31:29 +0800 Subject: [PATCH] fix(sqlite): stop compaction before hard-linked WAL mutation (#112224) * test(sqlite): align compact CLI report * fix(sqlite): protect state compaction sidecars * fix(sqlite): protect discovered session sidecars * fix(sqlite): scope session alias checks --- src/commands/doctor-session-sqlite.test.ts | 20 +++ src/commands/doctor-session-sqlite.ts | 23 +++ .../doctor-sqlite-maintenance-lock.ts | 28 +++- src/commands/doctor-state-sqlite-compact.ts | 2 + test/cli-state-sqlite.e2e.test.ts | 135 +++++++++++++++++- 5 files changed, 199 insertions(+), 9 deletions(-) diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index 41256d03467d..a17c20315810 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -626,6 +626,26 @@ describe("runDoctorSessionSqlite", () => { ); }); + it.skipIf(process.platform === "win32")( + "allows hard-linked legacy stores during SQLite compaction", + async () => { + const { store } = await createImportedStoreForCompaction(); + const externalStorePath = path.join(store.tempDir, "external-sessions.json"); + fs.writeFileSync(store.storePath, "{}\n", { mode: 0o600 }); + fs.linkSync(store.storePath, externalStorePath); + + const report = await runDoctorSessionSqlite({ + env: store.env, + mode: "compact", + store: store.storePath, + }); + + expect(report.totals.issues).toBe(0); + expect(fs.statSync(externalStorePath).nlink).toBe(2); + expect(fs.readFileSync(externalStorePath, "utf8")).toBe("{}\n"); + }, + ); + it("refuses compaction while this process owns an open agent database handle", async () => { const { sqlitePath, store } = await createImportedStoreForCompaction(); openOpenClawAgentDatabase({ diff --git a/src/commands/doctor-session-sqlite.ts b/src/commands/doctor-session-sqlite.ts index bbf7eb4c4758..45480bb7f64d 100644 --- a/src/commands/doctor-session-sqlite.ts +++ b/src/commands/doctor-session-sqlite.ts @@ -23,6 +23,7 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js"; import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js"; +import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { closeOpenClawAgentDatabaseByPath } from "../state/openclaw-agent-db.js"; import { compactDoctorSessionSqliteTarget } from "./doctor-session-sqlite-compact.js"; @@ -64,6 +65,10 @@ import { type DoctorSessionSqliteReport, type DoctorSessionSqliteTargetReport, } from "./doctor-session-sqlite-types.js"; +import { + assertDoctorSqliteMaintenancePathsNotHardLinked, + isDestructiveDoctorSessionSqliteMode, +} from "./doctor-sqlite-maintenance-lock.js"; export { restoreSessionSqliteMigrationRun, writeSessionSqliteMigrationFailureReports, @@ -98,6 +103,12 @@ export async function runDoctorSessionSqlite( mode: options.mode, store: options.store, }); + if (isDestructiveDoctorSessionSqliteMode(options.mode)) { + assertDoctorSqliteMaintenancePathsNotHardLinked( + `session SQLite ${options.mode}`, + resolveDoctorSessionSqliteMaintenancePaths(targets), + ); + } if (options.mode === "restore") { return restoreDoctorSessionSqliteTargets({ env, @@ -149,6 +160,18 @@ export async function runDoctorSessionSqlite( return summarizeDoctorSessionSqliteReport(options.mode, reports, activeRun); } +function resolveDoctorSessionSqliteMaintenancePaths( + targets: readonly SessionStoreTarget[], +): string[] { + const protectedPaths = new Set(); + for (const target of targets) { + for (const databasePath of resolveSqliteDatabaseFilePaths(resolveTargetSqlitePath(target))) { + protectedPaths.add(databasePath); + } + } + return [...protectedPaths]; +} + // Direct store migrations are scoped by path; broader agent discovery needs runtime config. function resolveDoctorSessionSqliteConfig(options: DoctorSessionSqliteOptions): OpenClawConfig { if (options.cfg) { diff --git a/src/commands/doctor-sqlite-maintenance-lock.ts b/src/commands/doctor-sqlite-maintenance-lock.ts index 403ce73053ef..3f28070391bb 100644 --- a/src/commands/doctor-sqlite-maintenance-lock.ts +++ b/src/commands/doctor-sqlite-maintenance-lock.ts @@ -56,12 +56,11 @@ function assertMaintenancePathsOwnedByStateDir( const stateCanonicalDir = resolvePathViaExistingAncestorSync(stateDir); for (const protectedPath of protectedPaths) { const absolutePath = path.resolve(protectedPath); - let resolvedPath: ReturnType; try { if (!isPathInside(stateDir, absolutePath) && !isPathInside(stateCanonicalDir, absolutePath)) { throw new Error("path is not lexically owned by the active state directory"); } - resolvedPath = resolveRootPathSync({ + resolveRootPathSync({ absolutePath, boundaryLabel: "OpenClaw state directory", rootCanonicalPath: stateCanonicalDir, @@ -73,11 +72,26 @@ function assertMaintenancePathsOwnedByStateDir( { cause: error }, ); } - if ( - resolvedPath.exists && - resolvedPath.kind === "file" && - fs.statSync(resolvedPath.canonicalPath).nlink > 1 - ) { + } + assertDoctorSqliteMaintenancePathsNotHardLinked(operation, protectedPaths); +} + +/** Reject file aliases that destructive SQLite maintenance would mutate in place. */ +export function assertDoctorSqliteMaintenancePathsNotHardLinked( + operation: string, + protectedPaths: readonly string[], +): void { + for (const protectedPath of new Set(protectedPaths.map((candidate) => path.resolve(candidate)))) { + let stat: fs.Stats; + try { + stat = fs.statSync(protectedPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + if (stat.isFile() && stat.nlink > 1) { throw new Error( `Cannot run ${operation} for a hard-linked path: ${protectedPath}. Remove the additional hard link and retry.`, ); diff --git a/src/commands/doctor-state-sqlite-compact.ts b/src/commands/doctor-state-sqlite-compact.ts index 2e09f57e8a03..5e3b78df66f1 100644 --- a/src/commands/doctor-state-sqlite-compact.ts +++ b/src/commands/doctor-state-sqlite-compact.ts @@ -1,5 +1,6 @@ /** Explicit doctor maintenance for the canonical shared state SQLite database. */ import fs from "node:fs"; +import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; import { clearOpenClawDatabaseQuarantine } from "../state/openclaw-quarantine-store.js"; import { assertOpenClawStateDatabaseForMaintenance, @@ -63,6 +64,7 @@ export async function runDoctorStateSqliteCompact( return await withMaintenanceLock({ env, operation: "state SQLite compaction", + protectedPaths: resolveSqliteDatabaseFilePaths(sqlitePath), run: () => { if (isOpenClawStateDatabaseOpen()) { throw new Error( diff --git a/test/cli-state-sqlite.e2e.test.ts b/test/cli-state-sqlite.e2e.test.ts index a7b79b4c2f38..8cc4436e3a3b 100644 --- a/test/cli-state-sqlite.e2e.test.ts +++ b/test/cli-state-sqlite.e2e.test.ts @@ -4,6 +4,10 @@ import fs from "node:fs"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it } from "vitest"; +import { + closeOpenClawAgentDatabaseByPath, + openOpenClawAgentDatabase, +} from "../src/state/openclaw-agent-db.js"; import { closeOpenClawStateDatabase, openOpenClawStateDatabase, @@ -67,7 +71,6 @@ describe("SQLite CLI maintenance ownership", () => { after: { autoVacuum: number; freelistPages: number }; before: { freelistPages: number }; integrityCheck: string; - quickCheck: string; skipped: boolean; }; expect(report).toMatchObject({ @@ -76,7 +79,6 @@ describe("SQLite CLI maintenance ownership", () => { freelistPages: 0, }, integrityCheck: "ok", - quickCheck: "ok", skipped: false, }); expect(report.before.freelistPages).toBeGreaterThan(0); @@ -86,6 +88,67 @@ describe("SQLite CLI maintenance ownership", () => { ); }, 90_000); + it.skipIf(process.platform === "win32")( + "rejects hard-linked shared-state SQLite sidecars before compaction", + async () => { + await withTempHome( + async (tempHome) => { + const stateDir = path.join(tempHome, ".openclaw"); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + }; + delete env.OPENCLAW_CONFIG_PATH; + delete env.OPENCLAW_HOME; + delete env.VITEST; + + const database = openOpenClawStateDatabase({ env }); + const walPath = `${database.path}-wal`; + const externalWalPath = path.join(tempHome, "external-state", "openclaw.sqlite-wal"); + try { + database.db.exec(` + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE compact_sidecar_payload ( + id INTEGER PRIMARY KEY, + payload TEXT NOT NULL + ); + PRAGMA wal_checkpoint(TRUNCATE); + INSERT INTO compact_sidecar_payload (payload) VALUES ('committed wal frame'); + `); + fs.mkdirSync(path.dirname(externalWalPath), { recursive: true }); + fs.linkSync(walPath, externalWalPath); + const externalWalBefore = fs.readFileSync(externalWalPath); + expect(externalWalBefore.byteLength).toBeGreaterThan(0); + + const entry = path.resolve(process.cwd(), "src/entry.ts"); + const result = spawnSync( + process.execPath, + ["--import", "tsx", entry, "doctor", "--state-sqlite", "compact", "--json"], + { + cwd: process.cwd(), + env, + encoding: "utf8", + timeout: 60_000, + }, + ); + + expect(result.status).not.toBe(0); + expect(`${result.stderr}\n${result.stdout}`).toContain("hard-linked path"); + expect(fs.readFileSync(externalWalPath)).toEqual(externalWalBefore); + } finally { + closeOpenClawStateDatabase(); + } + }, + { prefix: "openclaw-state-sqlite-sidecar-cli-" }, + ); + }, + 90_000, + ); + it("rejects destructive explicit session stores outside the active state owner", async () => { await withTempHome( async (tempHome) => { @@ -196,4 +259,72 @@ describe("SQLite CLI maintenance ownership", () => { { prefix: "openclaw-session-sqlite-sidecar-cli-" }, ); }, 90_000); + + it("rejects hard-linked SQLite sidecars discovered through configured session stores", async () => { + await withTempHome( + async (tempHome) => { + const stateDir = path.join(tempHome, ".openclaw"); + const storePath = path.join(tempHome, "external-sessions", "sessions.json"); + const sqlitePath = path.join(path.dirname(storePath), "openclaw-agent.sqlite"); + const externalWalPath = path.join(tempHome, "external-alias", "openclaw-agent.sqlite-wal"); + const configPath = path.join(stateDir, "openclaw.json"); + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.mkdirSync(path.dirname(externalWalPath), { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(storePath, "{}\n", "utf8"); + fs.writeFileSync(configPath, JSON.stringify({ session: { store: storePath } }), "utf8"); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_FAST: "1", + }; + delete env.OPENCLAW_HOME; + delete env.VITEST; + + const database = openOpenClawAgentDatabase({ + agentId: "main", + env, + path: sqlitePath, + }); + const walPath = `${sqlitePath}-wal`; + try { + database.db.exec(` + PRAGMA wal_autocheckpoint = 0; + CREATE TABLE compact_sidecar_payload ( + id INTEGER PRIMARY KEY, + payload TEXT NOT NULL + ); + PRAGMA wal_checkpoint(TRUNCATE); + INSERT INTO compact_sidecar_payload (payload) VALUES ('committed wal frame'); + `); + fs.linkSync(walPath, externalWalPath); + const externalWalBefore = fs.readFileSync(externalWalPath); + expect(externalWalBefore.byteLength).toBeGreaterThan(0); + + const entry = path.resolve(process.cwd(), "src/entry.ts"); + const result = spawnSync( + process.execPath, + ["--import", "tsx", entry, "doctor", "--session-sqlite", "compact", "--json"], + { + cwd: process.cwd(), + env, + encoding: "utf8", + timeout: 60_000, + }, + ); + + expect(result.status).not.toBe(0); + expect(`${result.stderr}\n${result.stdout}`).toContain("hard-linked path"); + expect(fs.readFileSync(externalWalPath)).toEqual(externalWalBefore); + } finally { + closeOpenClawAgentDatabaseByPath(sqlitePath); + } + }, + { prefix: "openclaw-configured-session-sqlite-sidecar-cli-" }, + ); + }, 90_000); });