fix(launchd): harden macOS launchagent install permissions

This commit is contained in:
Peter Steinberger
2026-03-09 08:14:46 +00:00
parent 3caab9260c
commit ce9e91fdfc
3 changed files with 78 additions and 6 deletions

View File

@@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
## 2026.3.8
### Changes

View File

@@ -19,7 +19,9 @@ const state = vi.hoisted(() => ({
printOutput: "",
bootstrapError: "",
dirs: new Set<string>(),
dirModes: new Map<string, number>(),
files: new Map<string, string>(),
fileModes: new Map<string, number>(),
}));
const defaultProgramArguments = ["node", "-e", "process.exit(0)"];
@@ -62,16 +64,41 @@ vi.mock("node:fs/promises", async (importOriginal) => {
}
throw new Error(`ENOENT: no such file or directory, access '${key}'`);
}),
mkdir: vi.fn(async (p: string) => {
state.dirs.add(String(p));
mkdir: vi.fn(async (p: string, opts?: { mode?: number }) => {
const key = String(p);
state.dirs.add(key);
state.dirModes.set(key, opts?.mode ?? 0o777);
}),
stat: vi.fn(async (p: string) => {
const key = String(p);
if (state.dirs.has(key)) {
return { mode: state.dirModes.get(key) ?? 0o777 };
}
if (state.files.has(key)) {
return { mode: state.fileModes.get(key) ?? 0o666 };
}
throw new Error(`ENOENT: no such file or directory, stat '${key}'`);
}),
chmod: vi.fn(async (p: string, mode: number) => {
const key = String(p);
if (state.dirs.has(key)) {
state.dirModes.set(key, mode);
return;
}
if (state.files.has(key)) {
state.fileModes.set(key, mode);
return;
}
throw new Error(`ENOENT: no such file or directory, chmod '${key}'`);
}),
unlink: vi.fn(async (p: string) => {
state.files.delete(String(p));
}),
writeFile: vi.fn(async (p: string, data: string) => {
writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => {
const key = String(p);
state.files.set(key, data);
state.dirs.add(String(key.split("/").slice(0, -1).join("/")));
state.fileModes.set(key, opts?.mode ?? 0o666);
}),
};
return { ...wrapped, default: wrapped };
@@ -83,7 +110,9 @@ beforeEach(() => {
state.printOutput = "";
state.bootstrapError = "";
state.dirs.clear();
state.dirModes.clear();
state.files.clear();
state.fileModes.clear();
vi.clearAllMocks();
});
@@ -255,6 +284,26 @@ describe("launchd install", () => {
expect(plist).toContain(`<integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>`);
});
it("tightens writable bits on launch agent dirs and plist", async () => {
const env = createDefaultLaunchdEnv();
state.dirs.add(env.HOME!);
state.dirModes.set(env.HOME!, 0o777);
state.dirs.add("/Users/test/Library");
state.dirModes.set("/Users/test/Library", 0o777);
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
});
const plistPath = resolveLaunchAgentPlistPath(env);
expect(state.dirModes.get(env.HOME!)).toBe(0o755);
expect(state.dirModes.get("/Users/test/Library")).toBe(0o755);
expect(state.dirModes.get("/Users/test/Library/LaunchAgents")).toBe(0o755);
expect(state.fileModes.get(plistPath)).toBe(0o644);
});
it("restarts LaunchAgent with bootout-enable-bootstrap-kickstart order", async () => {
const env = createDefaultLaunchdEnv();
await restartLaunchAgent({

View File

@@ -25,6 +25,9 @@ import type {
GatewayServiceManageArgs,
} from "./service-types.js";
const LAUNCH_AGENT_DIR_MODE = 0o755;
const LAUNCH_AGENT_PLIST_MODE = 0o644;
function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefined> }): string {
const envLabel = args?.env?.OPENCLAW_LAUNCHD_LABEL?.trim();
if (envLabel) {
@@ -112,6 +115,20 @@ function resolveGuiDomain(): string {
return `gui/${process.getuid()}`;
}
async function ensureSecureDirectory(targetPath: string): Promise<void> {
await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE });
try {
const stat = await fs.stat(targetPath);
const mode = stat.mode & 0o777;
const tightenedMode = mode & ~0o022;
if (tightenedMode !== mode) {
await fs.chmod(targetPath, tightenedMode);
}
} catch {
// Best effort: keep install working even if chmod/stat is unavailable.
}
}
export type LaunchctlPrintInfo = {
state?: string;
pid?: number;
@@ -382,7 +399,7 @@ export async function installLaunchAgent({
description,
}: GatewayServiceInstallArgs): Promise<{ plistPath: string }> {
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
await fs.mkdir(logDir, { recursive: true });
await ensureSecureDirectory(logDir);
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env });
@@ -398,7 +415,10 @@ export async function installLaunchAgent({
}
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
await fs.mkdir(path.dirname(plistPath), { recursive: true });
const home = resolveHomeDir(env);
await ensureSecureDirectory(home);
await ensureSecureDirectory(path.join(home, "Library"));
await ensureSecureDirectory(path.dirname(plistPath));
const serviceDescription = resolveGatewayServiceDescription({ env, environment, description });
const plist = buildLaunchAgentPlist({
@@ -410,7 +430,8 @@ export async function installLaunchAgent({
stderrPath,
environment,
});
await fs.writeFile(plistPath, plist, "utf8");
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
await execLaunchctl(["bootout", domain, plistPath]);
await execLaunchctl(["unload", plistPath]);