From 13dce4832158cd722f2c0d2e130a185f0a5ed72d Mon Sep 17 00:00:00 2001 From: Truffle Date: Wed, 10 Jun 2026 16:20:42 -0700 Subject: [PATCH] fix(state): tolerate chmod-less state volumes Keep state database startup working on filesystems without POSIX chmod support while failing closed for ordinary EPERM ownership failures. EPERM is tolerated only for already-private targets or when a disposable same-directory probe confirms chmod is unsupported.\n\nFixes #91919 --- .../openclaw-state-db.permissions.test.ts | 141 ++++++++++++++++++ src/state/openclaw-state-db.ts | 78 +++++++++- 2 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 src/state/openclaw-state-db.permissions.test.ts diff --git a/src/state/openclaw-state-db.permissions.test.ts b/src/state/openclaw-state-db.permissions.test.ts new file mode 100644 index 000000000000..cadec422beae --- /dev/null +++ b/src/state/openclaw-state-db.permissions.test.ts @@ -0,0 +1,141 @@ +// State database permission hardening tests cover best-effort chmod on +// filesystems without POSIX permission support (Azure Files, NFS, certain +// Docker volume drivers). +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// openclaw-state-db.ts hardens permissions via the named import `chmodSync` +// from node:fs. A namespace `vi.spyOn(fs, ...)` cannot rebind an +// already-captured named import, so we mock node:fs and route chmodSync +// (named + default) through a single controllable failure hook. +const chmodFailHook = vi.hoisted(() => ({ + error: undefined as Error | undefined, + calls: 0, + failProbe: true, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const chmodSync: typeof actual.chmodSync = ((target: unknown, mode: unknown) => { + chmodFailHook.calls += 1; + const isProbe = String(target).includes(".openclaw-chmod-probe-"); + if (chmodFailHook.error && (chmodFailHook.failProbe || !isProbe)) { + throw chmodFailHook.error; + } + return (actual.chmodSync as (...args: unknown[]) => unknown)(target, mode); + }) as typeof actual.chmodSync; + return { ...actual, chmodSync, default: { ...actual, chmodSync } }; +}); + +const fs = await import("node:fs"); +const { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, + repairOpenClawStateDatabaseSchema, + runOpenClawStateWriteTransaction, +} = await import("./openclaw-state-db.js"); + +function chmodError(code: string): Error { + const err = new Error(`${code}: chmod failed`) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +function enotsupError(): Error { + return chmodError("ENOTSUP"); +} + +describe("state database permission hardening without chmod support", () => { + let stateDir: string | undefined; + + afterEach(() => { + chmodFailHook.error = undefined; + chmodFailHook.calls = 0; + chmodFailHook.failProbe = true; + closeOpenClawStateDatabaseForTest(); + if (stateDir) { + fs.rmSync(stateDir, { recursive: true, force: true }); + stateDir = undefined; + } + }); + + it("opens the state database when chmodSync throws ENOTSUP", () => { + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + chmodFailHook.error = enotsupError(); + + const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); + + expect(database.db.isOpen).toBe(true); + // Hardening ran and failed; the failure must stay non-fatal. + expect(chmodFailHook.calls).toBeGreaterThan(0); + }); + + it("rethrows EPERM when existing permissions are too broad", () => { + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + fs.chmodSync(stateDir, 0o755); + chmodFailHook.error = chmodError("EPERM"); + chmodFailHook.failProbe = false; + + expect(() => openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } })).toThrow( + /EPERM/, + ); + }); + + it("opens when EPERM leaves existing permissions restrictive", () => { + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); + closeOpenClawStateDatabaseForTest(); + chmodFailHook.error = chmodError("EPERM"); + + const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); + + expect(database.db.isOpen).toBe(true); + }); + + it("opens when the filesystem probe also rejects chmod with EPERM", () => { + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + fs.chmodSync(stateDir, 0o755); + chmodFailHook.error = chmodError("EPERM"); + + const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); + + expect(database.db.isOpen).toBe(true); + }); + + it("rethrows unexpected chmod errors at open", () => { + // EACCES is not in CHMOD_UNSUPPORTED_CODES: a real permission fault on a + // POSIX filesystem must keep the credentials-adjacent hardening fatal. + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + chmodFailHook.error = chmodError("EACCES"); + + expect(() => openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } })).toThrow( + /EACCES/, + ); + }); + + it("repairs the schema when chmodSync throws ENOTSUP", () => { + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }); + closeOpenClawStateDatabaseForTest(); + + chmodFailHook.error = enotsupError(); + + expect(() => + repairOpenClawStateDatabaseSchema({ env: { OPENCLAW_STATE_DIR: stateDir } }), + ).not.toThrow(); + }); + + it("commits write transactions when chmodSync throws ENOTSUP", () => { + stateDir = fs.mkdtempSync(join(tmpdir(), "openclaw-state-chmod-")); + chmodFailHook.error = enotsupError(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + + const result = runOpenClawStateWriteTransaction((database) => { + expect(database.db.isOpen).toBe(true); + return "committed"; + }, options); + + expect(result).toBe("committed"); + }); +}); diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 05c2fba668bd..3847ab30aad7 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -1,6 +1,6 @@ // OpenClaw state database manages shared persisted state and migrations. import { randomUUID } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { @@ -11,6 +11,7 @@ import { import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js"; import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { resolveOpenClawStateSqliteDir, @@ -110,6 +111,77 @@ function assertSupportedSchemaVersion(db: DatabaseSync, pathname: string): void } } +const stateDbLog = createSubsystemLogger("state/db"); + +/** Targets already warned about, so chmod-less filesystems warn once per path. */ +const chmodWarnedTargets = new Set(); + +// Unambiguous errno codes raised when the filesystem cannot enforce POSIX modes. +const CHMOD_UNSUPPORTED_CODES = new Set(["ENOTSUP", "EOPNOTSUPP", "EINVAL"]); + +function hasRestrictivePermissions(target: string): boolean { + try { + return (statSync(target).mode & 0o077) === 0; + } catch { + return false; + } +} + +function filesystemRejectsChmod(target: string): boolean { + let probePath: string; + try { + const probeDir = statSync(target).isDirectory() ? target : path.dirname(target); + probePath = path.join(probeDir, `.openclaw-chmod-probe-${randomUUID()}`); + writeFileSync(probePath, "", { flag: "wx", mode: OPENCLAW_STATE_FILE_MODE }); + } catch { + return false; + } + try { + chmodSync(probePath, OPENCLAW_STATE_FILE_MODE); + return false; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } finally { + try { + unlinkSync(probePath); + } catch { + // The probe is best-effort cleanup after a failed capability check. + } + } +} + +function canIgnoreChmodError(target: string, code: string | undefined): boolean { + if (code && CHMOD_UNSUPPORTED_CODES.has(code)) { + return true; + } + if (code !== "EPERM") { + return false; + } + // EPERM is ambiguous: keep restrictive targets usable, otherwise prove the + // containing filesystem also rejects chmod before weakening fail-closed behavior. + return hasRestrictivePermissions(target) || filesystemRejectsChmod(target); +} + +// Permission hardening is best-effort only on filesystems that cannot apply +// it: the database stays usable without the chmod, and crashing at open would +// take the gateway down on Azure Files/NFS/Docker volumes (#91919). Unexpected +// chmod failures still throw so credentials-adjacent hardening stays loud. +function bestEffortChmodSync(target: string, mode: number): void { + try { + chmodSync(target, mode); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (!canIgnoreChmodError(target, code)) { + throw err; + } + if (chmodWarnedTargets.has(target)) { + return; + } + chmodWarnedTargets.add(target); + stateDbLog.warn(`skipped permission hardening for ${target}: ${String(err)}`); + } +} + function ensureOpenClawStatePermissions(pathname: string, env: NodeJS.ProcessEnv): void { const dir = path.dirname(pathname); const defaultDir = resolveOpenClawStateSqliteDir(env); @@ -122,12 +194,12 @@ function ensureOpenClawStatePermissions(pathname: string, env: NodeJS.ProcessEnv mkdirSync(dir, { recursive: true, mode: OPENCLAW_STATE_DIR_MODE }); // Default state contains credentials-adjacent metadata; custom existing dirs keep caller modes. if (isDefaultStateDatabase || !dirExisted) { - chmodSync(dir, OPENCLAW_STATE_DIR_MODE); + bestEffortChmodSync(dir, OPENCLAW_STATE_DIR_MODE); } for (const suffix of OPENCLAW_STATE_SIDECAR_SUFFIXES) { const candidate = `${pathname}${suffix}`; if (existsSync(candidate)) { - chmodSync(candidate, OPENCLAW_STATE_FILE_MODE); + bestEffortChmodSync(candidate, OPENCLAW_STATE_FILE_MODE); } } }