Skip to content

Commit 00160ea

Browse files
committed
fix(workboard): refuse unsafe SSHFS SQLite storage
Preserve rollback journaling for NFS and SMB-backed stores, refuse SSHFS after symlink-aware mount classification, and close Workboard database handles when filesystem policy rejects initialization.
1 parent ffb67d2 commit 00160ea

4 files changed

Lines changed: 297 additions & 48 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({
7+
close: vi.fn(),
8+
configureSqliteConnectionPragmas: vi.fn(),
9+
}));
10+
11+
vi.mock("node:sqlite", () => ({
12+
DatabaseSync: vi.fn(function DatabaseSync() {
13+
return { close };
14+
}),
15+
}));
16+
vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({
17+
configureSqliteConnectionPragmas,
18+
}));
19+
20+
import { createWorkboardSqliteStores } from "./sqlite-store.js";
21+
22+
describe("Workboard SQLite policy", () => {
23+
beforeEach(() => {
24+
close.mockClear();
25+
configureSqliteConnectionPragmas.mockReset();
26+
});
27+
28+
it("closes a newly opened database when filesystem policy refuses it", () => {
29+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-"));
30+
const dbPath = path.join(dir, "workboard.sqlite");
31+
configureSqliteConnectionPragmas.mockImplementation(() => {
32+
throw new Error("SSHFS is unsupported");
33+
});
34+
35+
try {
36+
expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/);
37+
expect(close).toHaveBeenCalledTimes(1);
38+
} finally {
39+
fs.rmSync(dir, { recursive: true, force: true });
40+
}
41+
});
42+
});

extensions/workboard/src/sqlite-store.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -390,17 +390,27 @@ function createDatabase(dbPath: string): {
390390
fs.closeSync(fs.openSync(dbPath, "a", WORKBOARD_SQLITE_FILE_MODE));
391391
}
392392
const db = new DatabaseSync(dbPath);
393-
const maintenance = configureSqliteConnectionPragmas(db, {
394-
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
395-
checkpointIntervalMs: 0,
396-
databaseLabel: "workboard database",
397-
databasePath: dbPath,
398-
foreignKeys: true,
399-
synchronous: "NORMAL",
400-
});
401-
ensureWorkboardSchema(db);
402-
hardenWorkboardDatabaseFiles(dbPath);
403-
return { db, maintenance };
393+
let maintenance: ReturnType<typeof configureSqliteConnectionPragmas> | undefined;
394+
try {
395+
maintenance = configureSqliteConnectionPragmas(db, {
396+
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
397+
checkpointIntervalMs: 0,
398+
databaseLabel: "workboard database",
399+
databasePath: dbPath,
400+
foreignKeys: true,
401+
synchronous: "NORMAL",
402+
});
403+
ensureWorkboardSchema(db);
404+
hardenWorkboardDatabaseFiles(dbPath);
405+
return { db, maintenance };
406+
} catch (error) {
407+
try {
408+
maintenance?.close();
409+
} finally {
410+
db.close();
411+
}
412+
throw error;
413+
}
404414
}
405415

406416
function childRows(db: DatabaseSync, table: string, cardId: string): Row[] {

src/infra/sqlite-wal.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,82 @@ describe("sqlite WAL maintenance", () => {
204204
}
205205
});
206206

207+
it("refuses fuse.sshfs mountinfo entries", () => {
208+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-"));
209+
try {
210+
const db = createMockDb();
211+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
212+
vi.spyOn(fs, "readFileSync").mockReturnValue(
213+
`42 12 0:41 / ${tempDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
214+
);
215+
216+
expect(() =>
217+
configureSqliteWalMaintenance(db, {
218+
checkpointIntervalMs: 0,
219+
databaseLabel: "test-db",
220+
databasePath: path.join(tempDir, "openclaw.sqlite"),
221+
}),
222+
).toThrow(/test-db .*SSHFS.*refusing to open/);
223+
224+
expect(db["prepare"]).not.toHaveBeenCalled();
225+
expect(db["exec"]).not.toHaveBeenCalled();
226+
} finally {
227+
fs.rmSync(tempDir, { recursive: true, force: true });
228+
}
229+
});
230+
231+
it("refuses symlinked paths into fuse.sshfs mounts", () => {
232+
if (process.platform === "win32") {
233+
return;
234+
}
235+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-link-"));
236+
const mountDir = path.join(tempDir, "mount");
237+
const linkedDir = path.join(tempDir, "linked");
238+
try {
239+
fs.mkdirSync(mountDir);
240+
fs.symlinkSync(mountDir, linkedDir);
241+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
242+
vi.spyOn(fs, "readFileSync").mockReturnValue(
243+
`42 12 0:41 / ${mountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
244+
);
245+
246+
expect(() =>
247+
configureSqliteWalMaintenance(createMockDb(), {
248+
checkpointIntervalMs: 0,
249+
databasePath: path.join(linkedDir, "openclaw.sqlite"),
250+
}),
251+
).toThrow(/SSHFS.*refusing to open/);
252+
} finally {
253+
fs.rmSync(tempDir, { recursive: true, force: true });
254+
}
255+
});
256+
257+
it("matches raw mount paths when the existing path canonicalizes elsewhere", () => {
258+
if (process.platform === "win32") {
259+
return;
260+
}
261+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-prefix-"));
262+
const canonicalMountDir = path.join(tempDir, "canonical-mount");
263+
const rawMountDir = path.join(tempDir, "raw-mount");
264+
try {
265+
fs.mkdirSync(canonicalMountDir);
266+
fs.symlinkSync(canonicalMountDir, rawMountDir);
267+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
268+
vi.spyOn(fs, "readFileSync").mockReturnValue(
269+
`42 12 0:41 / ${rawMountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
270+
);
271+
272+
expect(() =>
273+
configureSqliteWalMaintenance(createMockDb(), {
274+
checkpointIntervalMs: 0,
275+
databasePath: path.join(rawMountDir, "openclaw.sqlite"),
276+
}),
277+
).toThrow(/SSHFS.*refusing to open/);
278+
} finally {
279+
fs.rmSync(tempDir, { recursive: true, force: true });
280+
}
281+
});
282+
207283
it("uses mount command filesystem names on platforms without proc mountinfo", () => {
208284
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
209285
try {
@@ -250,6 +326,60 @@ describe("sqlite WAL maintenance", () => {
250326
}
251327
});
252328

329+
it.each([
330+
["macfuse", "sshfs#user@host:/share"],
331+
["macfuse", "host:/share"],
332+
["macfuse", "user@host:"],
333+
["osxfuse", "user@host:/share"],
334+
["osxfuse", "sshfs@osxfuse0"],
335+
])("refuses SSHFS reported as %s by mount", (fsType, source) => {
336+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-macfuse-"));
337+
try {
338+
const db = createMockDb();
339+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
340+
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
341+
throw new Error("no proc mountinfo");
342+
});
343+
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
344+
Buffer.from(`${source} on ${tempDir} (${fsType}, nodev, nosuid)\n`),
345+
);
346+
347+
expect(() =>
348+
configureSqliteWalMaintenance(db, {
349+
checkpointIntervalMs: 0,
350+
databasePath: path.join(tempDir, "openclaw.sqlite"),
351+
}),
352+
).toThrow(/refusing to open/);
353+
354+
expect(db["exec"]).not.toHaveBeenCalled();
355+
} finally {
356+
fs.rmSync(tempDir, { recursive: true, force: true });
357+
}
358+
});
359+
360+
it("keeps WAL enabled for non-remote macFUSE mounts", () => {
361+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-macfuse-"));
362+
try {
363+
const db = createMockDb();
364+
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
365+
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
366+
throw new Error("no proc mountinfo");
367+
});
368+
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
369+
Buffer.from(`remote-volume on ${tempDir} (macfuse, nodev, nosuid)\n`),
370+
);
371+
372+
configureSqliteWalMaintenance(db, {
373+
checkpointIntervalMs: 0,
374+
databasePath: path.join(tempDir, "openclaw.sqlite"),
375+
});
376+
377+
expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;");
378+
} finally {
379+
fs.rmSync(tempDir, { recursive: true, force: true });
380+
}
381+
});
382+
253383
it("parses Linux mount command filesystem names when proc mountinfo is unavailable", () => {
254384
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
255385
try {

0 commit comments

Comments
 (0)