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