diff --git a/extensions/workboard/src/sqlite-store-policy.test.ts b/extensions/workboard/src/sqlite-store-policy.test.ts new file mode 100644 index 00000000000..9211b290195 --- /dev/null +++ b/extensions/workboard/src/sqlite-store-policy.test.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({ + close: vi.fn(), + configureSqliteConnectionPragmas: vi.fn(), +})); + +vi.mock("node:sqlite", () => ({ + DatabaseSync: vi.fn(function DatabaseSync() { + return { close }; + }), +})); +vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({ + configureSqliteConnectionPragmas, +})); + +import { createWorkboardSqliteStores } from "./sqlite-store.js"; + +describe("Workboard SQLite policy", () => { + beforeEach(() => { + close.mockClear(); + configureSqliteConnectionPragmas.mockReset(); + }); + + it("closes a newly opened database when filesystem policy refuses it", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-")); + const dbPath = path.join(dir, "workboard.sqlite"); + configureSqliteConnectionPragmas.mockImplementation(() => { + throw new Error("SSHFS is unsupported"); + }); + + try { + expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/); + expect(close).toHaveBeenCalledTimes(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/workboard/src/sqlite-store.ts b/extensions/workboard/src/sqlite-store.ts index d4617ef5563..4b4fe6d25a8 100644 --- a/extensions/workboard/src/sqlite-store.ts +++ b/extensions/workboard/src/sqlite-store.ts @@ -390,17 +390,27 @@ function createDatabase(dbPath: string): { fs.closeSync(fs.openSync(dbPath, "a", WORKBOARD_SQLITE_FILE_MODE)); } const db = new DatabaseSync(dbPath); - const maintenance = configureSqliteConnectionPragmas(db, { - busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS, - checkpointIntervalMs: 0, - databaseLabel: "workboard database", - databasePath: dbPath, - foreignKeys: true, - synchronous: "NORMAL", - }); - ensureWorkboardSchema(db); - hardenWorkboardDatabaseFiles(dbPath); - return { db, maintenance }; + let maintenance: ReturnType | undefined; + try { + maintenance = configureSqliteConnectionPragmas(db, { + busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS, + checkpointIntervalMs: 0, + databaseLabel: "workboard database", + databasePath: dbPath, + foreignKeys: true, + synchronous: "NORMAL", + }); + ensureWorkboardSchema(db); + hardenWorkboardDatabaseFiles(dbPath); + return { db, maintenance }; + } catch (error) { + try { + maintenance?.close(); + } finally { + db.close(); + } + throw error; + } } function childRows(db: DatabaseSync, table: string, cardId: string): Row[] { diff --git a/src/infra/sqlite-wal.test.ts b/src/infra/sqlite-wal.test.ts index 4e68db4c3db..b01a110214a 100644 --- a/src/infra/sqlite-wal.test.ts +++ b/src/infra/sqlite-wal.test.ts @@ -204,6 +204,82 @@ describe("sqlite WAL maintenance", () => { } }); + it("refuses fuse.sshfs mountinfo entries", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-")); + try { + const db = createMockDb(); + vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0)); + vi.spyOn(fs, "readFileSync").mockReturnValue( + `42 12 0:41 / ${tempDir} rw,relatime - fuse.sshfs user@host:/share rw\n`, + ); + + expect(() => + configureSqliteWalMaintenance(db, { + checkpointIntervalMs: 0, + databaseLabel: "test-db", + databasePath: path.join(tempDir, "openclaw.sqlite"), + }), + ).toThrow(/test-db .*SSHFS.*refusing to open/); + + expect(db["prepare"]).not.toHaveBeenCalled(); + expect(db["exec"]).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("refuses symlinked paths into fuse.sshfs mounts", () => { + if (process.platform === "win32") { + return; + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-link-")); + const mountDir = path.join(tempDir, "mount"); + const linkedDir = path.join(tempDir, "linked"); + try { + fs.mkdirSync(mountDir); + fs.symlinkSync(mountDir, linkedDir); + vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0)); + vi.spyOn(fs, "readFileSync").mockReturnValue( + `42 12 0:41 / ${mountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`, + ); + + expect(() => + configureSqliteWalMaintenance(createMockDb(), { + checkpointIntervalMs: 0, + databasePath: path.join(linkedDir, "openclaw.sqlite"), + }), + ).toThrow(/SSHFS.*refusing to open/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("matches raw mount paths when the existing path canonicalizes elsewhere", () => { + if (process.platform === "win32") { + return; + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-prefix-")); + const canonicalMountDir = path.join(tempDir, "canonical-mount"); + const rawMountDir = path.join(tempDir, "raw-mount"); + try { + fs.mkdirSync(canonicalMountDir); + fs.symlinkSync(canonicalMountDir, rawMountDir); + vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0)); + vi.spyOn(fs, "readFileSync").mockReturnValue( + `42 12 0:41 / ${rawMountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`, + ); + + expect(() => + configureSqliteWalMaintenance(createMockDb(), { + checkpointIntervalMs: 0, + databasePath: path.join(rawMountDir, "openclaw.sqlite"), + }), + ).toThrow(/SSHFS.*refusing to open/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("uses mount command filesystem names on platforms without proc mountinfo", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-")); try { @@ -250,6 +326,60 @@ describe("sqlite WAL maintenance", () => { } }); + it.each([ + ["macfuse", "sshfs#user@host:/share"], + ["macfuse", "host:/share"], + ["macfuse", "user@host:"], + ["osxfuse", "user@host:/share"], + ["osxfuse", "sshfs@osxfuse0"], + ])("refuses SSHFS reported as %s by mount", (fsType, source) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-macfuse-")); + try { + const db = createMockDb(); + vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0)); + vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw new Error("no proc mountinfo"); + }); + vi.spyOn(childProcess, "execFileSync").mockReturnValue( + Buffer.from(`${source} on ${tempDir} (${fsType}, nodev, nosuid)\n`), + ); + + expect(() => + configureSqliteWalMaintenance(db, { + checkpointIntervalMs: 0, + databasePath: path.join(tempDir, "openclaw.sqlite"), + }), + ).toThrow(/refusing to open/); + + expect(db["exec"]).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps WAL enabled for non-remote macFUSE mounts", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-macfuse-")); + try { + const db = createMockDb(); + vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0)); + vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw new Error("no proc mountinfo"); + }); + vi.spyOn(childProcess, "execFileSync").mockReturnValue( + Buffer.from(`remote-volume on ${tempDir} (macfuse, nodev, nosuid)\n`), + ); + + configureSqliteWalMaintenance(db, { + checkpointIntervalMs: 0, + databasePath: path.join(tempDir, "openclaw.sqlite"), + }); + + expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("parses Linux mount command filesystem names when proc mountinfo is unavailable", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-")); try { diff --git a/src/infra/sqlite-wal.ts b/src/infra/sqlite-wal.ts index 7c23ccbc0f0..fa5fbfee1be 100644 --- a/src/infra/sqlite-wal.ts +++ b/src/infra/sqlite-wal.ts @@ -26,6 +26,8 @@ type IntervalHandle = ReturnType & { }; type SqliteWalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"; +type SqliteFilesystemJournalPolicy = "rollback" | "unsupported" | "wal"; +type MountEntry = { mountPoint: string; fsType: string; source?: string }; export type SqliteWalMaintenance = { checkpoint: () => boolean; @@ -55,19 +57,27 @@ function normalizeNonNegativeInteger(value: number, label: string): number { return value; } -function findExistingVolumePath(targetPath: string): string | null { +function findExistingVolumePaths( + targetPath: string, +): { canonicalPath: string; originalPath: string } | null { let current = path.resolve(targetPath); while (true) { + let stats: ReturnType; try { - const stats = fs.statSync(current); - return stats.isDirectory() ? current : path.dirname(current); + stats = fs.statSync(current); } catch { const parent = path.dirname(current); if (parent === current) { return null; } current = parent; + continue; } + const existingPath = fs.realpathSync(current); + return { + canonicalPath: stats.isDirectory() ? existingPath : path.dirname(existingPath), + originalPath: stats.isDirectory() ? current : path.dirname(current), + }; } } @@ -77,10 +87,8 @@ function decodeMountPath(value: string): string { ); } -function parseProcMountInfoEntries( - contents: string, -): Array<{ mountPoint: string; fsType: string }> { - const entries: Array<{ mountPoint: string; fsType: string }> = []; +function parseProcMountInfoEntries(contents: string): MountEntry[] { + const entries: MountEntry[] = []; for (const line of contents.split("\n")) { const separator = line.indexOf(" - "); if (separator === -1) { @@ -91,34 +99,38 @@ function parseProcMountInfoEntries( const mountPoint = fields[4]; const fsType = suffixFields[0]; if (mountPoint && fsType) { - entries.push({ mountPoint: decodeMountPath(mountPoint), fsType }); + entries.push({ + mountPoint: decodeMountPath(mountPoint), + fsType, + ...(suffixFields[1] ? { source: decodeMountPath(suffixFields[1]) } : {}), + }); } } return entries; } -function parseMountCommandEntries(contents: string): Array<{ mountPoint: string; fsType: string }> { - const entries: Array<{ mountPoint: string; fsType: string }> = []; +function parseMountCommandEntries(contents: string): MountEntry[] { + const entries: MountEntry[] = []; for (const line of contents.split("\n")) { - const linuxMatch = /^.* on (.+) type ([^,\s)]+) \(/.exec(line); + const linuxMatch = /^(.+) on (.+) type ([^,\s)]+) \(/.exec(line); if (linuxMatch) { - entries.push({ mountPoint: linuxMatch[1], fsType: linuxMatch[2] }); + entries.push({ source: linuxMatch[1], mountPoint: linuxMatch[2], fsType: linuxMatch[3] }); continue; } - const bsdMatch = /^.* on (.+) \(([^,\s)]+)/.exec(line); + const bsdMatch = /^(.+) on (.+) \(([^,\s)]+)/.exec(line); if (bsdMatch) { - entries.push({ mountPoint: bsdMatch[1], fsType: bsdMatch[2] }); + entries.push({ source: bsdMatch[1], mountPoint: bsdMatch[2], fsType: bsdMatch[3] }); } } return entries; } -function readMountEntries(): Array<{ mountPoint: string; fsType: string }> { +function readMountEntries(): MountEntry[] { try { return parseProcMountInfoEntries(fs.readFileSync(PROC_MOUNTINFO_PATH, "utf8")); } catch { // macOS/BSD expose filesystem type names in `mount` output instead of - // Linux superblock magic, so keep this fallback for non-Linux NFS mounts. + // Linux superblock magic, so keep this fallback for named filesystem types. } try { return parseMountCommandEntries(String(childProcess.execFileSync("mount", []))); @@ -137,16 +149,54 @@ function isPathWithinMount(targetPath: string, mountPoint: string): boolean { ); } -function isNetworkMountType(fsType: string): boolean { - const normalized = fsType.toLowerCase(); - return normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized); +function isSshfsMountSource(source: string | undefined): boolean { + if (!source) { + return false; + } + const normalized = source.toLowerCase(); + return ( + normalized === "sshfs" || + normalized.startsWith("sshfs#") || + normalized.startsWith("sshfs@") || + /^(?:[^/\s:]+@)?[^/\s:]+:.*/u.test(source) + ); } -function isNetworkMountEntryPath(targetPath: string): boolean { - const mountEntry = readMountEntries() +function resolveMountTypeJournalPolicy(entry: MountEntry): SqliteFilesystemJournalPolicy { + const normalized = entry.fsType.toLowerCase(); + if (normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized)) { + return "rollback"; + } + if (normalized === "fuse.sshfs") { + return "unsupported"; + } + if ((normalized === "macfuse" || normalized === "osxfuse") && isSshfsMountSource(entry.source)) { + return "unsupported"; + } + return "wal"; +} + +function resolveMountEntryJournalPolicy( + targetPath: string, + mountEntries: MountEntry[], +): SqliteFilesystemJournalPolicy { + const mountEntry = mountEntries .filter((entry) => isPathWithinMount(targetPath, entry.mountPoint)) .toSorted((a, b) => b.mountPoint.length - a.mountPoint.length)[0]; - return mountEntry ? isNetworkMountType(mountEntry.fsType) : false; + return mountEntry ? resolveMountTypeJournalPolicy(mountEntry) : "wal"; +} + +function combineMountEntryJournalPolicies( + targetPaths: readonly string[], +): SqliteFilesystemJournalPolicy { + const mountEntries = readMountEntries(); + const policies = new Set( + targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountEntries)), + ); + if (policies.has("unsupported")) { + return "unsupported"; + } + return policies.has("rollback") ? "rollback" : "wal"; } function isWindowsUncPath(targetPath: string): boolean { @@ -160,43 +210,46 @@ function isWindowsDrivePath(targetPath: string): boolean { return /^[A-Za-z]:[\\/]/.test(targetPath) || /^\\\\\?\\[A-Za-z]:[\\/]/i.test(targetPath); } -function isNetworkBackedPath(targetPath: string): boolean { +function resolvePathJournalPolicy(targetPath: string): SqliteFilesystemJournalPolicy { if (process.platform === "win32") { const normalizedTargetPath = path.win32.normalize(targetPath); if (isWindowsUncPath(normalizedTargetPath)) { - return true; + return "rollback"; } if (isWindowsDrivePath(normalizedTargetPath)) { try { - return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath))); + return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath))) + ? "rollback" + : "wal"; } catch { // Windows can deny SMB path normalization when parent components are // unreadable. Treat an unclassifiable opened database as network-backed. - return true; + return "rollback"; } } } - if (typeof fs.statfsSync !== "function") { - return isNetworkMountEntryPath(targetPath); + const checkedPaths = findExistingVolumePaths(targetPath); + if (!checkedPaths) { + return "wal"; } - const checkedPath = findExistingVolumePath(targetPath); - if (!checkedPath) { - return false; + const mountLookupPaths = [checkedPaths.originalPath, checkedPaths.canonicalPath]; + if (typeof fs.statfsSync !== "function") { + return combineMountEntryJournalPolicies(mountLookupPaths); } try { - const filesystemType = fs.statfsSync(checkedPath).type; + const filesystemType = fs.statfsSync(checkedPaths.canonicalPath).type; if ( filesystemType === LINUX_NFS_SUPER_MAGIC || filesystemType === LINUX_SMB_SUPER_MAGIC || filesystemType === LINUX_CIFS_SUPER_MAGIC || filesystemType === LINUX_SMB2_SUPER_MAGIC ) { - return true; + return "rollback"; } } catch { - return isNetworkMountEntryPath(checkedPath); + return combineMountEntryJournalPolicies(mountLookupPaths); } - return isNetworkMountEntryPath(checkedPath); + return combineMountEntryJournalPolicies(mountLookupPaths); } function readJournalModeResult(row: unknown): string | null { @@ -221,7 +274,15 @@ function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintena } } -/** Configure WAL pragmas and return a handle for checkpoint/close maintenance. */ +function refuseUnsupportedFilesystem(options: SqliteWalMaintenanceOptions): never { + const label = options.databaseLabel ?? "sqlite database"; + const location = options.databasePath ? ` at ${options.databasePath}` : ""; + throw new Error( + `${label}${location} is on SSHFS, which cannot safely coordinate SQLite writes across mounts; refusing to open the database.`, + ); +} + +/** Configure safe journaling pragmas and return a handle for checkpoint/close maintenance. */ export function configureSqliteWalMaintenance( db: DatabaseSync, options: SqliteWalMaintenanceOptions = {}, @@ -237,7 +298,13 @@ export function configureSqliteWalMaintenance( const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS); const checkpointMode = options.checkpointMode ?? "TRUNCATE"; const periodicCheckpointMode = options.checkpointMode ?? "PASSIVE"; - if (options.databasePath && isNetworkBackedPath(options.databasePath)) { + const journalPolicy = options.databasePath + ? resolvePathJournalPolicy(options.databasePath) + : "wal"; + if (journalPolicy === "unsupported") { + refuseUnsupportedFilesystem(options); + } + if (journalPolicy === "rollback") { requireRollbackJournalMode(db, options); return { checkpoint: () => true,