mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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
This commit is contained in:
@@ -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<typeof import("node:fs")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user