From ce9e91fdfcc89ce16934f70c63380d3adb05cff2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 08:14:46 +0000 Subject: [PATCH] fix(launchd): harden macOS launchagent install permissions --- CHANGELOG.md | 2 ++ src/daemon/launchd.test.ts | 55 +++++++++++++++++++++++++++++++++++--- src/daemon/launchd.ts | 27 ++++++++++++++++--- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e092ef78685..f987feeec35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3ebf2a22aed..99e5e1f933e 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -19,7 +19,9 @@ const state = vi.hoisted(() => ({ printOutput: "", bootstrapError: "", dirs: new Set(), + dirModes: new Map(), files: new Map(), + fileModes: new Map(), })); 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(`${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}`); }); + 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({ diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index dccea5780ed..11e0bd50d20 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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 { 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 { + 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]);