From c69fc383b909758acf787288b075a641b4712516 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 04:24:55 +0100 Subject: [PATCH] fix(config): surface helpful chown hint on EACCES when reading config When the gateway is deployed in a Docker/container environment using a 1-click hosting template, the openclaw.json config file can end up owned by root (mode 600) while the gateway process runs as the non-root 'node' user. This causes a silent EACCES failure: the gateway starts with an empty config and Telegram/Discord bots stop responding. Before this fix the error was logged as a generic 'read failed: ...' message with no indication of how to recover. After this fix: - EACCES errors log a clear, actionable error to stderr (visible in docker logs) with the exact chown command to run - The config snapshot issue message also includes the chown hint so 'openclaw gateway status' / Control UI surface the fix path - process.getuid() is used to include the current UID in the hint; falls back to '1001' on platforms where it is unavailable Fixes #24853 (cherry picked from commit 0a3c572c4175953b0d1284993642b1689678fce4) --- src/config/io.eacces.test.ts | 60 ++++++++++++++++++++++++++++++++++++ src/config/io.ts | 21 ++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/config/io.eacces.test.ts diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts new file mode 100644 index 00000000000..f22b9d8905d --- /dev/null +++ b/src/config/io.eacces.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createConfigIO } from "./io.js"; + +function makeEaccesFs(configPath: string) { + const eaccesErr = Object.assign(new Error(`EACCES: permission denied, open '${configPath}'`), { + code: "EACCES", + }); + return { + existsSync: (p: string) => p === configPath, + readFileSync: (p: string): string => { + if (p === configPath) { + throw eaccesErr; + } + throw new Error(`unexpected readFileSync: ${p}`); + }, + promises: { + readFile: () => Promise.reject(eaccesErr), + mkdir: () => Promise.resolve(), + writeFile: () => Promise.resolve(), + appendFile: () => Promise.resolve(), + }, + } as unknown as typeof import("node:fs").default; +} + +describe("config io EACCES handling", () => { + it("returns a helpful error message when config file is not readable (EACCES)", async () => { + const configPath = "/data/.openclaw/openclaw.json"; + const errors: string[] = []; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { + error: (msg: unknown) => errors.push(String(msg)), + warn: () => {}, + }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + expect(snapshot.issues).toHaveLength(1); + expect(snapshot.issues[0].message).toContain("EACCES"); + expect(snapshot.issues[0].message).toContain("chown"); + expect(snapshot.issues[0].message).toContain(configPath); + // Should also emit to the logger + expect(errors.some((e) => e.includes("chown"))).toBe(true); + }); + + it("includes configPath in the chown hint for the correct remediation command", async () => { + const configPath = "/home/myuser/.openclaw/openclaw.json"; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { error: () => {}, warn: () => {} }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.issues[0].message).toContain(configPath); + expect(snapshot.issues[0].message).toContain("container"); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index bff292048fb..8dbcf10936c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -936,6 +936,25 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { envSnapshotForRestore: readResolution.envSnapshotForRestore, }; } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + let message: string; + if (nodeErr?.code === "EACCES") { + // Permission denied — common in Docker/container deployments where the + // config file is owned by root but the gateway runs as a non-root user. + const uid = process.getuid?.(); + const uidHint = typeof uid === "number" ? String(uid) : "$(id -u)"; + message = [ + `read failed: ${String(err)}`, + ``, + `Config file is not readable by the current process. If running in a container`, + `or 1-click deployment, fix ownership with:`, + ` chown ${uidHint} "${configPath}"`, + `Then restart the gateway.`, + ].join("\n"); + deps.logger.error(message); + } else { + message = `read failed: ${String(err)}`; + } return { snapshot: { path: configPath, @@ -946,7 +965,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { valid: false, config: {}, hash: hashConfigRaw(null), - issues: [{ path: "", message: `read failed: ${String(err)}` }], + issues: [{ path: "", message }], warnings: [], legacyIssues: [], },