mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
fix(gateway): harden macOS launchd service startup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
10
src/daemon/runtime-format.test.ts
Normal file
10
src/daemon/runtime-format.test.ts
Normal 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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user