fix(launchd): harden macOS launchagent install permissions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user