Skip to content

Commit d7c173b

Browse files
committed
fix(gateway): harden macOS launchd service startup
1 parent 6fed787 commit d7c173b

9 files changed

Lines changed: 164 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- 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.
910
- 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.
1011
- 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.
1112
- 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.

src/commands/daemon-install-helpers.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ function mockNodeGatewayPlanFixture(
6666
} = {},
6767
) {
6868
const {
69-
workingDirectory = "/Users/me",
7069
version = "22.0.0",
7170
supported = true,
7271
warning,
7372
serviceEnvironment = { OPENCLAW_PORT: "3000" },
7473
} = params;
74+
const workingDirectory = Object.hasOwn(params, "workingDirectory")
75+
? params.workingDirectory
76+
: "/Users/me";
7577
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
7678
mocks.resolveGatewayProgramArguments.mockResolvedValue({
7779
programArguments: ["node", "gateway"],
@@ -166,6 +168,43 @@ describe("buildGatewayInstallPlan", () => {
166168
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
167169
});
168170

171+
it("uses the state dir as the default macOS launchd working directory", async () => {
172+
mockNodeGatewayPlanFixture({
173+
workingDirectory: undefined,
174+
serviceEnvironment: {},
175+
});
176+
177+
const plan = await buildGatewayInstallPlan({
178+
env: isolatedPlanEnv(),
179+
port: 3000,
180+
runtime: "node",
181+
platform: "darwin",
182+
});
183+
184+
expect(plan.workingDirectory).toBe(path.join(isolatedHome, ".openclaw"));
185+
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
186+
expect.objectContaining({
187+
platform: "darwin",
188+
}),
189+
);
190+
});
191+
192+
it("does not invent a working directory for non-macOS service installs", async () => {
193+
mockNodeGatewayPlanFixture({
194+
workingDirectory: undefined,
195+
serviceEnvironment: {},
196+
});
197+
198+
const plan = await buildGatewayInstallPlan({
199+
env: isolatedPlanEnv(),
200+
port: 3000,
201+
runtime: "node",
202+
platform: "linux",
203+
});
204+
205+
expect(plan.workingDirectory).toBeUndefined();
206+
});
207+
169208
it("merges safe config env while dropping unsafe values and keeping service precedence", async () => {
170209
mockNodeGatewayPlanFixture({
171210
serviceEnvironment: {

src/commands/daemon-install-helpers.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { formatCliCommand } from "../cli/command-format.js";
55
import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js";
66
import type { OpenClawConfig } from "../config/types.js";
77
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
8+
import { resolveGatewayStateDir } from "../daemon/paths.js";
89
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
910
import { buildServiceEnvironment } from "../daemon/service-env.js";
1011
import {
@@ -212,6 +213,20 @@ function collectPreservedExistingServiceEnvVars(
212213
return preserved;
213214
}
214215

216+
function resolveGatewayInstallWorkingDirectory(params: {
217+
env: Record<string, string | undefined>;
218+
platform: NodeJS.Platform;
219+
workingDirectory: string | undefined;
220+
}): string | undefined {
221+
if (params.workingDirectory) {
222+
return params.workingDirectory;
223+
}
224+
if (params.platform !== "darwin") {
225+
return undefined;
226+
}
227+
return resolveGatewayStateDir(params.env);
228+
}
229+
215230
async function buildGatewayInstallEnvironment(params: {
216231
env: Record<string, string | undefined>;
217232
config?: OpenClawConfig;
@@ -261,11 +276,13 @@ export async function buildGatewayInstallPlan(params: {
261276
existingEnvironment?: Record<string, string | undefined>;
262277
devMode?: boolean;
263278
nodePath?: string;
279+
platform?: NodeJS.Platform;
264280
warn?: DaemonInstallWarnFn;
265281
/** Full config to extract env vars from (env vars + inline env keys). */
266282
config?: OpenClawConfig;
267283
authStore?: AuthProfileStore;
268284
}): Promise<GatewayInstallPlan> {
285+
const platform = params.platform ?? process.platform;
269286
const { devMode, nodePath } = await resolveDaemonInstallRuntimeInputs({
270287
env: params.env,
271288
runtime: params.runtime,
@@ -289,16 +306,21 @@ export async function buildGatewayInstallPlan(params: {
289306
env: params.env,
290307
port: params.port,
291308
launchdLabel:
292-
process.platform === "darwin"
309+
platform === "darwin"
293310
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
294311
: undefined,
312+
platform,
295313
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
296314
});
297315

298316
// Lowest to highest: preserved custom vars, durable config, auth env refs, generated service env.
299317
return {
300318
programArguments,
301-
workingDirectory,
319+
workingDirectory: resolveGatewayInstallWorkingDirectory({
320+
env: params.env,
321+
platform,
322+
workingDirectory,
323+
}),
302324
environment: await buildGatewayInstallEnvironment({
303325
env: params.env,
304326
config: params.config,

src/daemon/launchd.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ describe("launchd install", () => {
451451

452452
it("writes TMPDIR to LaunchAgent environment when provided", async () => {
453453
const env = createDefaultLaunchdEnv();
454-
const tmpDir = "/var/folders/xy/abc123/T/";
454+
const tmpDir = "/Users/test/.openclaw/tmp";
455455
await installLaunchAgent({
456456
env,
457457
stdout: new PassThrough(),
@@ -466,6 +466,20 @@ describe("launchd install", () => {
466466
expect(plist).toContain(`<string>${tmpDir}</string>`);
467467
});
468468

469+
it("creates the LaunchAgent TMPDIR before bootstrap", async () => {
470+
const env = createDefaultLaunchdEnv();
471+
const tmpDir = "/Users/test/.openclaw/tmp";
472+
await installLaunchAgent({
473+
env,
474+
stdout: new PassThrough(),
475+
programArguments: defaultProgramArguments,
476+
environment: { TMPDIR: tmpDir },
477+
});
478+
479+
expect(state.dirs.has(tmpDir)).toBe(true);
480+
expect(state.dirModes.get(tmpDir)).toBe(0o700);
481+
});
482+
469483
it("writes KeepAlive=true policy with restrictive umask", async () => {
470484
const env = createDefaultLaunchdEnv();
471485
await installLaunchAgent({

src/daemon/launchd.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {
3636

3737
const LAUNCH_AGENT_DIR_MODE = 0o755;
3838
const LAUNCH_AGENT_PLIST_MODE = 0o644;
39+
const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700;
3940

4041
function assertValidLaunchAgentLabel(label: string): string {
4142
const trimmed = label.trim();
@@ -209,12 +210,16 @@ async function bootstrapLaunchAgentOrThrow(params: {
209210
throw new Error(`launchctl bootstrap failed: ${detail}`);
210211
}
211212

212-
async function ensureSecureDirectory(targetPath: string): Promise<void> {
213-
await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE });
213+
async function ensureSecureDirectory(
214+
targetPath: string,
215+
dirMode = LAUNCH_AGENT_DIR_MODE,
216+
): Promise<void> {
217+
await fs.mkdir(targetPath, { recursive: true, mode: dirMode });
214218
try {
215219
const stat = await fs.stat(targetPath);
216220
const mode = stat.mode & 0o777;
217-
const tightenedMode = mode & ~0o022;
221+
const forbiddenMode = dirMode === LAUNCH_AGENT_PRIVATE_DIR_MODE ? 0o077 : 0o022;
222+
const tightenedMode = mode & ~forbiddenMode;
218223
if (tightenedMode !== mode) {
219224
await fs.chmod(targetPath, tightenedMode);
220225
}
@@ -223,6 +228,15 @@ async function ensureSecureDirectory(targetPath: string): Promise<void> {
223228
}
224229
}
225230

231+
async function ensureLaunchAgentEnvironmentDirectories(
232+
environment: Record<string, string | undefined> | undefined,
233+
): Promise<void> {
234+
const tmpDir = environment?.TMPDIR?.trim();
235+
if (tmpDir) {
236+
await ensureSecureDirectory(tmpDir, LAUNCH_AGENT_PRIVATE_DIR_MODE);
237+
}
238+
}
239+
226240
export type LaunchctlPrintInfo = {
227241
state?: string;
228242
pid?: number;
@@ -535,6 +549,7 @@ async function writeLaunchAgentPlist({
535549
await ensureSecureDirectory(home);
536550
await ensureSecureDirectory(libraryDir);
537551
await ensureSecureDirectory(path.dirname(plistPath));
552+
await ensureLaunchAgentEnvironmentDirectories(environment);
538553

539554
const serviceDescription = resolveGatewayServiceDescription({ env, environment, description });
540555
const plist = buildLaunchAgentPlist({

src/daemon/runtime-format.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { describe, expect, it } from "vitest";
2+
import { formatRuntimeStatus } from "./runtime-format.js";
3+
4+
describe("formatRuntimeStatus", () => {
5+
it("labels abort-shaped launchd exit statuses", () => {
6+
expect(formatRuntimeStatus({ status: "stopped", lastExitStatus: 134 })).toContain(
7+
"last exit 134 (SIGABRT/abort)",
8+
);
9+
});
10+
});

src/daemon/runtime-format.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ export type ServiceRuntimeLike = {
1212
detail?: string;
1313
};
1414

15+
const SIGNAL_NAMES_BY_STATUS = new Map<number, string>([
16+
[129, "SIGHUP"],
17+
[130, "SIGINT"],
18+
[131, "SIGQUIT"],
19+
[134, "SIGABRT/abort"],
20+
[137, "SIGKILL"],
21+
[143, "SIGTERM"],
22+
]);
23+
24+
function formatLastExitStatus(status: number): string {
25+
const signalName = SIGNAL_NAMES_BY_STATUS.get(status);
26+
return signalName ? `last exit ${status} (${signalName})` : `last exit ${status}`;
27+
}
28+
1529
export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): string | null {
1630
if (!runtime) {
1731
return null;
@@ -21,7 +35,7 @@ export function formatRuntimeStatus(runtime: ServiceRuntimeLike | undefined): st
2135
details.push(`sub ${runtime.subState}`);
2236
}
2337
if (runtime.lastExitStatus !== undefined) {
24-
details.push(`last exit ${runtime.lastExitStatus}`);
38+
details.push(formatLastExitStatus(runtime.lastExitStatus));
2539
}
2640
if (runtime.lastExitReason) {
2741
details.push(`reason ${runtime.lastExitReason}`);

src/daemon/service-env.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,18 +398,29 @@ describe("buildServiceEnvironment", () => {
398398
}
399399
});
400400

401-
it("forwards TMPDIR from the host environment", () => {
401+
it("forwards TMPDIR from the host environment on Linux", () => {
402402
const env = buildServiceEnvironment({
403403
env: { HOME: "/home/user", TMPDIR: "/var/folders/xw/abc123/T/" },
404404
port: 18789,
405+
platform: "linux",
405406
});
406407
expect(env.TMPDIR).toBe("/var/folders/xw/abc123/T/");
407408
});
408409

409-
it("falls back to os.tmpdir when TMPDIR is not set", () => {
410+
it("uses a durable state temp directory for macOS LaunchAgents", () => {
411+
const env = buildServiceEnvironment({
412+
env: { HOME: "/Users/user", TMPDIR: "/var/folders/xw/abc123/T/" },
413+
port: 18789,
414+
platform: "darwin",
415+
});
416+
expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp"));
417+
});
418+
419+
it("falls back to os.tmpdir when TMPDIR is not set on Linux", () => {
410420
const env = buildServiceEnvironment({
411421
env: { HOME: "/home/user" },
412422
port: 18789,
423+
platform: "linux",
413424
});
414425
expect(env.TMPDIR).toBe(os.tmpdir());
415426
});
@@ -519,16 +530,26 @@ describe("buildNodeServiceEnvironment", () => {
519530
expect(env.no_proxy).toBe("localhost,127.0.0.1");
520531
});
521532

522-
it("forwards TMPDIR for node services", () => {
533+
it("forwards TMPDIR for node services on Linux", () => {
523534
const env = buildNodeServiceEnvironment({
524535
env: { HOME: "/home/user", TMPDIR: "/tmp/custom" },
536+
platform: "linux",
525537
});
526538
expect(env.TMPDIR).toBe("/tmp/custom");
527539
});
528540

529-
it("falls back to os.tmpdir for node services when TMPDIR is not set", () => {
541+
it("uses a durable state temp directory for macOS node services", () => {
542+
const env = buildNodeServiceEnvironment({
543+
env: { HOME: "/Users/user", TMPDIR: "/var/folders/xw/abc123/T/" },
544+
platform: "darwin",
545+
});
546+
expect(env.TMPDIR).toBe(path.join("/Users/user", ".openclaw", "tmp"));
547+
});
548+
549+
it("falls back to os.tmpdir for node services when TMPDIR is not set on Linux", () => {
530550
const env = buildNodeServiceEnvironment({
531551
env: { HOME: "/home/user" },
552+
platform: "linux",
532553
});
533554
expect(env.TMPDIR).toBe(os.tmpdir());
534555
});

src/daemon/service-env.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
resolveNodeSystemdServiceName,
2121
resolveNodeWindowsTaskName,
2222
} from "./constants.js";
23+
import { resolveGatewayStateDir } from "./paths.js";
2324

2425
export { isNodeVersionManagerRuntime, resolveLinuxSystemCaBundle };
2526

@@ -360,6 +361,20 @@ function buildCommonServiceEnvironment(
360361
return serviceEnv;
361362
}
362363

364+
function resolveServiceTmpDir(
365+
env: Record<string, string | undefined>,
366+
platform: NodeJS.Platform,
367+
): string {
368+
if (platform === "darwin") {
369+
try {
370+
return path.join(resolveGatewayStateDir(env), "tmp");
371+
} catch {
372+
return env.TMPDIR?.trim() || os.tmpdir();
373+
}
374+
}
375+
return env.TMPDIR?.trim() || os.tmpdir();
376+
}
377+
363378
function resolveSharedServiceEnvironmentFields(
364379
env: Record<string, string | undefined>,
365380
platform: NodeJS.Platform,
@@ -368,8 +383,7 @@ function resolveSharedServiceEnvironmentFields(
368383
): SharedServiceEnvironmentFields {
369384
const stateDir = env.OPENCLAW_STATE_DIR;
370385
const configPath = env.OPENCLAW_CONFIG_PATH;
371-
// Keep a usable temp directory for supervised services even when the host env omits TMPDIR.
372-
const tmpDir = env.TMPDIR?.trim() || os.tmpdir();
386+
const tmpDir = resolveServiceTmpDir(env, platform);
373387
const proxyEnv = readServiceProxyEnvironment(env);
374388
// On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch
375389
// cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification

0 commit comments

Comments
 (0)