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
This commit is contained in:
Vincent Koc
2026-07-21 16:31:29 +08:00
committed by GitHub
parent 3d3fd97c7d
commit 1a595b97d6
5 changed files with 199 additions and 9 deletions
@@ -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({
+23
View File
@@ -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<string>();
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) {
+21 -7
View File
@@ -56,12 +56,11 @@ function assertMaintenancePathsOwnedByStateDir(
const stateCanonicalDir = resolvePathViaExistingAncestorSync(stateDir);
for (const protectedPath of protectedPaths) {
const absolutePath = path.resolve(protectedPath);
let resolvedPath: ReturnType<typeof resolveRootPathSync>;
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.`,
);
@@ -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(
+133 -2
View File
@@ -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);
});