From d7c173b6945687985a60842e500aeabec522f060 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 17:18:26 -0700 Subject: [PATCH] fix(gateway): harden macOS launchd service startup --- CHANGELOG.md | 1 + src/commands/daemon-install-helpers.test.ts | 41 ++++++++++++++++++++- src/commands/daemon-install-helpers.ts | 26 ++++++++++++- src/daemon/launchd.test.ts | 16 +++++++- src/daemon/launchd.ts | 21 +++++++++-- src/daemon/runtime-format.test.ts | 10 +++++ src/daemon/runtime-format.ts | 16 +++++++- src/daemon/service-env.test.ts | 29 +++++++++++++-- src/daemon/service-env.ts | 18 ++++++++- 9 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 src/daemon/runtime-format.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3b26a87e8..3e7ce714884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius. - Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex. - Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq. - Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han. diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 70e022ef385..df00aeeb754 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -66,12 +66,14 @@ function mockNodeGatewayPlanFixture( } = {}, ) { const { - workingDirectory = "/Users/me", version = "22.0.0", supported = true, warning, serviceEnvironment = { OPENCLAW_PORT: "3000" }, } = params; + const workingDirectory = Object.hasOwn(params, "workingDirectory") + ? params.workingDirectory + : "/Users/me"; mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); mocks.resolveGatewayProgramArguments.mockResolvedValue({ programArguments: ["node", "gateway"], @@ -166,6 +168,43 @@ describe("buildGatewayInstallPlan", () => { expect(mocks.resolvePreferredNodePath).toHaveBeenCalled(); }); + it("uses the state dir as the default macOS launchd working directory", async () => { + mockNodeGatewayPlanFixture({ + workingDirectory: undefined, + serviceEnvironment: {}, + }); + + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv(), + port: 3000, + runtime: "node", + platform: "darwin", + }); + + expect(plan.workingDirectory).toBe(path.join(isolatedHome, ".openclaw")); + expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "darwin", + }), + ); + }); + + it("does not invent a working directory for non-macOS service installs", async () => { + mockNodeGatewayPlanFixture({ + workingDirectory: undefined, + serviceEnvironment: {}, + }); + + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv(), + port: 3000, + runtime: "node", + platform: "linux", + }); + + expect(plan.workingDirectory).toBeUndefined(); + }); + it("merges safe config env while dropping unsafe values and keeping service precedence", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 0d811cdd838..0f477141690 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; +import { resolveGatewayStateDir } from "../daemon/paths.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { @@ -212,6 +213,20 @@ function collectPreservedExistingServiceEnvVars( return preserved; } +function resolveGatewayInstallWorkingDirectory(params: { + env: Record; + platform: NodeJS.Platform; + workingDirectory: string | undefined; +}): string | undefined { + if (params.workingDirectory) { + return params.workingDirectory; + } + if (params.platform !== "darwin") { + return undefined; + } + return resolveGatewayStateDir(params.env); +} + async function buildGatewayInstallEnvironment(params: { env: Record; config?: OpenClawConfig; @@ -261,11 +276,13 @@ export async function buildGatewayInstallPlan(params: { existingEnvironment?: Record; devMode?: boolean; nodePath?: string; + platform?: NodeJS.Platform; warn?: DaemonInstallWarnFn; /** Full config to extract env vars from (env vars + inline env keys). */ config?: OpenClawConfig; authStore?: AuthProfileStore; }): Promise { + const platform = params.platform ?? process.platform; const { devMode, nodePath } = await resolveDaemonInstallRuntimeInputs({ env: params.env, runtime: params.runtime, @@ -289,16 +306,21 @@ export async function buildGatewayInstallPlan(params: { env: params.env, port: params.port, launchdLabel: - process.platform === "darwin" + platform === "darwin" ? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE) : undefined, + platform, extraPathDirs: resolveDaemonNodeBinDir(nodePath), }); // Lowest to highest: preserved custom vars, durable config, auth env refs, generated service env. return { programArguments, - workingDirectory, + workingDirectory: resolveGatewayInstallWorkingDirectory({ + env: params.env, + platform, + workingDirectory, + }), environment: await buildGatewayInstallEnvironment({ env: params.env, config: params.config, diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3cacdeee259..e0f36f86072 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -451,7 +451,7 @@ describe("launchd install", () => { it("writes TMPDIR to LaunchAgent environment when provided", async () => { const env = createDefaultLaunchdEnv(); - const tmpDir = "/var/folders/xy/abc123/T/"; + const tmpDir = "/Users/test/.openclaw/tmp"; await installLaunchAgent({ env, stdout: new PassThrough(), @@ -466,6 +466,20 @@ describe("launchd install", () => { expect(plist).toContain(`${tmpDir}`); }); + it("creates the LaunchAgent TMPDIR before bootstrap", async () => { + const env = createDefaultLaunchdEnv(); + const tmpDir = "/Users/test/.openclaw/tmp"; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + environment: { TMPDIR: tmpDir }, + }); + + expect(state.dirs.has(tmpDir)).toBe(true); + expect(state.dirModes.get(tmpDir)).toBe(0o700); + }); + it("writes KeepAlive=true policy with restrictive umask", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent({ diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index a79eb3da4b8..f1c12d098b5 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -36,6 +36,7 @@ import type { const LAUNCH_AGENT_DIR_MODE = 0o755; const LAUNCH_AGENT_PLIST_MODE = 0o644; +const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700; function assertValidLaunchAgentLabel(label: string): string { const trimmed = label.trim(); @@ -209,12 +210,16 @@ async function bootstrapLaunchAgentOrThrow(params: { throw new Error(`launchctl bootstrap failed: ${detail}`); } -async function ensureSecureDirectory(targetPath: string): Promise { - await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE }); +async function ensureSecureDirectory( + targetPath: string, + dirMode = LAUNCH_AGENT_DIR_MODE, +): Promise { + await fs.mkdir(targetPath, { recursive: true, mode: dirMode }); try { const stat = await fs.stat(targetPath); const mode = stat.mode & 0o777; - const tightenedMode = mode & ~0o022; + const forbiddenMode = dirMode === LAUNCH_AGENT_PRIVATE_DIR_MODE ? 0o077 : 0o022; + const tightenedMode = mode & ~forbiddenMode; if (tightenedMode !== mode) { await fs.chmod(targetPath, tightenedMode); } @@ -223,6 +228,15 @@ async function ensureSecureDirectory(targetPath: string): Promise { } } +async function ensureLaunchAgentEnvironmentDirectories( + environment: Record | undefined, +): Promise { + const tmpDir = environment?.TMPDIR?.trim(); + if (tmpDir) { + await ensureSecureDirectory(tmpDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); + } +} + export type LaunchctlPrintInfo = { state?: string; pid?: number; @@ -535,6 +549,7 @@ async function writeLaunchAgentPlist({ await ensureSecureDirectory(home); await ensureSecureDirectory(libraryDir); await ensureSecureDirectory(path.dirname(plistPath)); + await ensureLaunchAgentEnvironmentDirectories(environment); const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const plist = buildLaunchAgentPlist({ diff --git a/src/daemon/runtime-format.test.ts b/src/daemon/runtime-format.test.ts new file mode 100644 index 00000000000..c98fc7d29b3 --- /dev/null +++ b/src/daemon/runtime-format.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { formatRuntimeStatus } from "./runtime-format.js"; + +describe("formatRuntimeStatus", () => { + it("labels abort-shaped launchd exit statuses", () => { + expect(formatRuntimeStatus({ status: "stopped", lastExitStatus: 134 })).toContain( + "last exit 134 (SIGABRT/abort)", + ); + }); +}); diff --git a/src/daemon/runtime-format.ts b/src/daemon/runtime-format.ts index 67155ab69bd..a2248febc02 100644 --- a/src/daemon/runtime-format.ts +++ b/src/daemon/runtime-format.ts @@ -12,6 +12,20 @@ export type ServiceRuntimeLike = { detail?: string; }; +const SIGNAL_NAMES_BY_STATUS = new Map([ + [129, "SIGHUP"], + [130, "SIGINT"], + [131, "SIGQUIT"], + [134, "SIGABRT/abort"], + [137, "SIGKILL"], + [143, "SIGTERM"], +]); + +function formatLastExitStatus(status: number): string { + const signalName = SIGNAL_NAMES_BY_STATUS.get(status); + return signalName ? `last exit ${status} (${signalName})` : `last exit ${status}`; +} + export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): string | null { if (!runtime) { return null; @@ -21,7 +35,7 @@ export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): st details.push(`sub ${runtime.subState}`); } if (runtime.lastExitStatus !== undefined) { - details.push(`last exit ${runtime.lastExitStatus}`); + details.push(formatLastExitStatus(runtime.lastExitStatus)); } if (runtime.lastExitReason) { details.push(`reason ${runtime.lastExitReason}`); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 1396b769f3c..983399eb6bd 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -398,18 +398,29 @@ describe("buildServiceEnvironment", () => { } }); - it("forwards TMPDIR from the host environment", () => { + it("forwards TMPDIR from the host environment on Linux", () => { const env = buildServiceEnvironment({ env: { HOME: "/home/user", TMPDIR: "/var/folders/xw/abc123/T/" }, port: 18789, + platform: "linux", }); expect(env.TMPDIR).toBe("/var/folders/xw/abc123/T/"); }); - it("falls back to os.tmpdir when TMPDIR is not set", () => { + it("uses a durable state temp directory for macOS LaunchAgents", () => { + const env = buildServiceEnvironment({ + env: { HOME: "/Users/user", TMPDIR: "/var/folders/xw/abc123/T/" }, + port: 18789, + platform: "darwin", + }); + expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp")); + }); + + it("falls back to os.tmpdir when TMPDIR is not set on Linux", () => { const env = buildServiceEnvironment({ env: { HOME: "/home/user" }, port: 18789, + platform: "linux", }); expect(env.TMPDIR).toBe(os.tmpdir()); }); @@ -519,16 +530,26 @@ describe("buildNodeServiceEnvironment", () => { expect(env.no_proxy).toBe("localhost,127.0.0.1"); }); - it("forwards TMPDIR for node services", () => { + it("forwards TMPDIR for node services on Linux", () => { const env = buildNodeServiceEnvironment({ env: { HOME: "/home/user", TMPDIR: "/tmp/custom" }, + platform: "linux", }); expect(env.TMPDIR).toBe("/tmp/custom"); }); - it("falls back to os.tmpdir for node services when TMPDIR is not set", () => { + it("uses a durable state temp directory for macOS node services", () => { + const env = buildNodeServiceEnvironment({ + env: { HOME: "/Users/user", TMPDIR: "/var/folders/xw/abc123/T/" }, + platform: "darwin", + }); + expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp")); + }); + + it("falls back to os.tmpdir for node services when TMPDIR is not set on Linux", () => { const env = buildNodeServiceEnvironment({ env: { HOME: "/home/user" }, + platform: "linux", }); expect(env.TMPDIR).toBe(os.tmpdir()); }); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index baa652501be..c2fddf395f6 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -20,6 +20,7 @@ import { resolveNodeSystemdServiceName, resolveNodeWindowsTaskName, } from "./constants.js"; +import { resolveGatewayStateDir } from "./paths.js"; export { isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle }; @@ -360,6 +361,20 @@ function buildCommonServiceEnvironment( return serviceEnv; } +function resolveServiceTmpDir( + env: Record, + platform: NodeJS.Platform, +): string { + if (platform === "darwin") { + try { + return path.join(resolveGatewayStateDir(env), "tmp"); + } catch { + return env.TMPDIR?.trim() || os.tmpdir(); + } + } + return env.TMPDIR?.trim() || os.tmpdir(); +} + function resolveSharedServiceEnvironmentFields( env: Record, platform: NodeJS.Platform, @@ -368,8 +383,7 @@ function resolveSharedServiceEnvironmentFields( ): SharedServiceEnvironmentFields { const stateDir = env.OPENCLAW_STATE_DIR; const configPath = env.OPENCLAW_CONFIG_PATH; - // Keep a usable temp directory for supervised services even when the host env omits TMPDIR. - const tmpDir = env.TMPDIR?.trim() || os.tmpdir(); + const tmpDir = resolveServiceTmpDir(env, platform); const proxyEnv = readServiceProxyEnvironment(env); // On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch // cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification