fix(gateway): harden macOS launchd service startup

This commit is contained in:
Vincent Koc
2026-04-26 17:18:26 -07:00
parent 6fed787297
commit d7c173b694
9 changed files with 164 additions and 14 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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<string, string | undefined>;
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<string, string | undefined>;
config?: OpenClawConfig;
@@ -261,11 +276,13 @@ export async function buildGatewayInstallPlan(params: {
existingEnvironment?: Record<string, string | undefined>;
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<GatewayInstallPlan> {
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,

View File

@@ -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(`<string>${tmpDir}</string>`);
});
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({

View File

@@ -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<void> {
await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE });
async function ensureSecureDirectory(
targetPath: string,
dirMode = LAUNCH_AGENT_DIR_MODE,
): Promise<void> {
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<void> {
}
}
async function ensureLaunchAgentEnvironmentDirectories(
environment: Record<string, string | undefined> | undefined,
): Promise<void> {
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({

View File

@@ -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)",
);
});
});

View File

@@ -12,6 +12,20 @@ export type ServiceRuntimeLike = {
detail?: string;
};
const SIGNAL_NAMES_BY_STATUS = new Map<number, string>([
[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}`);

View File

@@ -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());
});

View File

@@ -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<string, string | undefined>,
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<string, string | undefined>,
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