fix: add root guard to prevent CLI execution as root (#67478)

Block openclaw CLI from running as root (uid 0) to prevent:
- Separate state directory at /root/.openclaw/
- Conflicting systemd user services racing on port 18789
- Root-owned files in the service user state dir (EACCES)

The guard runs early in src/entry.ts before any state/config operations.
Root-level --help and --version bypass the guard so users can discover
the OPENCLAW_ALLOW_ROOT=1 override. Subcommand help paths still enforce
the guard since they enter runCli() and resolve state directories.

Closes #67478
This commit is contained in:
忻役
2026-04-16 12:26:30 +08:00
committed by Sally O'Malley
parent 741315e657
commit ca8121d22b
3 changed files with 114 additions and 1 deletions

View File

@@ -0,0 +1,71 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("assertNotRoot", () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
// Save and restore real getuid so we can replace it per test.
const realGetuid = process.getuid;
beforeEach(() => {
exitSpy.mockClear();
stderrSpy.mockClear();
process.getuid = realGetuid;
});
afterAll(() => {
exitSpy.mockRestore();
stderrSpy.mockRestore();
process.getuid = realGetuid;
});
// Use a fresh import each time to avoid module-level caching issues.
async function loadAssertNotRoot() {
const mod = await import("./root-guard.js");
return mod.assertNotRoot;
}
it("exits with code 1 when uid is 0 and no env override", async () => {
process.getuid = () => 0;
const assertNotRoot = await loadAssertNotRoot();
assertNotRoot({});
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("does not exit when uid is 0 and OPENCLAW_ALLOW_ROOT=1", async () => {
process.getuid = () => 0;
const assertNotRoot = await loadAssertNotRoot();
assertNotRoot({ OPENCLAW_ALLOW_ROOT: "1" });
expect(exitSpy).not.toHaveBeenCalled();
});
it("does not exit when uid is non-zero", async () => {
process.getuid = () => 1000;
const assertNotRoot = await loadAssertNotRoot();
assertNotRoot({});
expect(exitSpy).not.toHaveBeenCalled();
});
it("does not exit when getuid is undefined (Windows)", async () => {
process.getuid = undefined as unknown as typeof process.getuid;
const assertNotRoot = await loadAssertNotRoot();
assertNotRoot({});
expect(exitSpy).not.toHaveBeenCalled();
});
it("error message mentions OPENCLAW_ALLOW_ROOT", async () => {
process.getuid = () => 0;
const assertNotRoot = await loadAssertNotRoot();
assertNotRoot({});
const output = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
expect(output).toContain("OPENCLAW_ALLOW_ROOT");
});
it("error message mentions running as a non-root user", async () => {
process.getuid = () => 0;
const assertNotRoot = await loadAssertNotRoot();
assertNotRoot({});
const output = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
expect(output).toContain("non-root user");
});
});

34
src/cli/root-guard.ts Normal file
View File

@@ -0,0 +1,34 @@
import process from "node:process";
/**
* Block CLI execution when running as root (uid 0) unless explicitly opted in.
*
* Running as root causes:
* - Separate state dir (/root/.openclaw/ vs /home/<user>/.openclaw/)
* - Conflicting systemd user services (port 18789 race)
* - Root-owned files in the service user's state dir (EACCES)
*/
export function assertNotRoot(env: NodeJS.ProcessEnv = process.env): void {
if (typeof process.getuid !== "function") {
return;
}
if (process.getuid() !== 0) {
return;
}
if (env.OPENCLAW_ALLOW_ROOT === "1") {
return;
}
process.stderr.write(
"[openclaw] Refusing to run as root.\n" +
"\n" +
"Running the CLI as root causes:\n" +
" - A separate state directory under /root/.openclaw/ instead of the service user's\n" +
" - Conflicting systemd user services that race on port 18789\n" +
" - Root-owned files in the service user's state dir (EACCES errors)\n" +
"\n" +
"Run as a non-root user (e.g. su - <service-user>),\n" +
"or override this check:\n" +
" OPENCLAW_ALLOW_ROOT=1 openclaw ...\n",
);
process.exit(1);
}