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 0a3c572c41)
This commit is contained in:
zerone0x
2026-02-24 04:24:55 +01:00
committed by Peter Steinberger
parent f3459d71e8
commit c69fc383b9
2 changed files with 80 additions and 1 deletions

View File

@@ -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");
});
});

View File

@@ -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: [],
},