refactor(daemon): split launchd into concept modules (#121929)

* refactor(agents): split launchd into concept modules

* fix(daemon): restore launchd service environment type

* refactor(daemon): keep launchd probe result internal
This commit is contained in:
Peter Steinberger
2026-08-11 01:49:23 -07:00
committed by GitHub
parent f0d6cc4ade
commit 3d707a9b96
8 changed files with 2132 additions and 2011 deletions
-1
View File
@@ -635,7 +635,6 @@ src/cron/service/ops.test.ts
src/cron/service/timer.regression.test.ts
src/cron/store.test.ts
src/daemon/launchd.test.ts
src/daemon/launchd.ts
src/daemon/schtasks.startup-fallback.test.ts
src/daemon/systemd.test.ts
src/daemon/systemd.ts
+423
View File
@@ -0,0 +1,423 @@
/** Transactional LaunchAgent installation, staging, rollback, and removal. */
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveLegacyGatewayLaunchAgentLabels } from "./constants.js";
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
import {
execLaunchctl,
formatLaunchctlResultDetail,
isLaunchctlNotLoaded,
} from "./launchd-exec.js";
import { assertValidLaunchAgentLabel, resolveLaunchAgentLabel } from "./launchd-label.js";
import {
bootstrapLaunchAgentOrThrow,
probeLaunchAgentState,
resolveLaunchAgentGuiDomain,
} from "./launchd-runtime.js";
import {
LAUNCH_AGENT_ENV_FILE_MODE,
LAUNCH_AGENT_ENV_WRAPPER_MODE,
LAUNCH_AGENT_PLIST_MODE,
publishLaunchAgentPlist,
readExistingLaunchAgentPlist,
resolveLaunchAgentEnvFilePath,
resolveLaunchAgentEnvWrapperPath,
resolveLaunchAgentPlistPath,
resolveLaunchAgentPlistPathForLabel,
writeLaunchAgentPlist,
} from "./launchd-service-files.js";
import { assertNoSystemLaunchDaemonOwnership } from "./launchd-system.js";
import { formatLine, toPosixPath, writeFormattedLines } from "./output.js";
import { resolveDaemonHomeDir } from "./paths.js";
import type {
GatewayServiceEnv,
GatewayServiceInstallArgs,
GatewayServiceManageArgs,
} from "./service-types.js";
export async function uninstallLaunchAgent({
env,
stdout,
}: GatewayServiceManageArgs): Promise<void> {
assertExternalLaunchAgentMutation(env, "uninstall");
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(env);
const plistPath = resolveLaunchAgentPlistPath(env);
const bootout = await execLaunchctl(["bootout", domain, plistPath]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
}
try {
await fs.lstat(plistPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw createLaunchAgentRemovalError(error);
}
stdout.write(`LaunchAgent not found at ${plistPath}\n`);
return;
}
const home = toPosixPath(resolveDaemonHomeDir(env));
const trashDir = path.posix.join(home, ".Trash");
const dest = path.join(trashDir, `${label}.plist`);
try {
await fs.mkdir(trashDir, { recursive: true });
await fs.rename(plistPath, dest);
stdout.write(`${formatLine("Moved LaunchAgent to Trash", dest)}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
try {
await fs.lstat(plistPath);
} catch (accessError) {
if ((accessError as NodeJS.ErrnoException).code === "ENOENT") {
stdout.write(`LaunchAgent not found at ${plistPath}\n`);
return;
}
throw createLaunchAgentRemovalError(accessError);
}
}
throw createLaunchAgentRemovalError(error);
}
}
function createLaunchAgentRemovalError(error: unknown): Error {
const code = (error as NodeJS.ErrnoException).code;
return new Error(
`LaunchAgent removal failed${code ? ` (${code})` : ""}. Check permissions and retry.`,
);
}
function currentGatewayLaunchAgentLabel(
targetEnv: Record<string, string | undefined>,
): string | undefined {
const configuredCurrentLabel = process.env.OPENCLAW_LAUNCHD_LABEL?.trim();
const candidates = new Set([
resolveLaunchAgentLabel(targetEnv),
...(configuredCurrentLabel ? [assertValidLaunchAgentLabel(configuredCurrentLabel)] : []),
]);
return [...candidates].find((label) =>
isCurrentProcessLaunchdServiceLabel(label, process.env, {
allowConfiguredLabelFallback: false,
}),
);
}
function assertExternalLaunchAgentMutation(
env: Record<string, string | undefined>,
action: "install" | "uninstall",
): void {
const currentLabel = currentGatewayLaunchAgentLabel(env);
if (!currentLabel) {
return;
}
throw new Error(
`Refusing to ${action} LaunchAgent ${resolveLaunchAgentLabel(env)} from inside ${currentLabel}; run this command from an external shell.`,
);
}
export async function stageLaunchAgent({
stdout,
...args
}: GatewayServiceInstallArgs): Promise<{ plistPath: string }> {
const { plistPath, stdoutPath } = await writeLaunchAgentPlist({ ...args, stdout });
writeFormattedLines(
stdout,
[
{ label: "Staged LaunchAgent", value: plistPath },
{ label: "Logs", value: stdoutPath },
],
{ leadingBlankLine: true },
);
return { plistPath };
}
type LaunchAgentInstallSnapshot = {
plistContents: Buffer | null;
envFileContents: Buffer | null;
wrapperContents: Buffer | null;
legacy: Array<{
label: string;
plistPath: string;
contents: Buffer | null;
loaded: boolean;
}>;
loaded: boolean;
};
async function snapshotLaunchAgentLoadedState(
plistContents: Buffer | null,
serviceTarget: string,
): Promise<boolean> {
const probe = await probeLaunchAgentState(serviceTarget);
if (probe.state === "unknown") {
throw new Error(
`launchctl print could not determine whether ${serviceTarget} is loaded: ${probe.detail ?? "unknown error"}`,
);
}
const loaded = probe.state !== "not-loaded";
if (loaded && plistContents === null) {
// launchd can retain a definition after its plist is deleted. Booting that
// job out would destroy the only copy, so no exact rollback is possible.
throw new Error(
`LaunchAgent ${serviceTarget} is loaded but its plist is missing; refusing an install that cannot restore the current definition if activation fails.`,
);
}
return loaded;
}
async function restoreLaunchAgentOwnedFile(params: {
path: string;
contents: Buffer | null;
mode: number;
}): Promise<void> {
if (params.contents === null) {
await fs.unlink(params.path).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
});
return;
}
const temporaryPath = `${params.path}.openclaw-${randomUUID()}.rollback`;
try {
await fs.writeFile(temporaryPath, params.contents.toString("utf8"), {
flag: "wx",
mode: params.mode,
});
await fs.rename(temporaryPath, params.path);
await fs.chmod(params.path, params.mode).catch(() => undefined);
} finally {
await fs.unlink(temporaryPath).catch(() => undefined);
}
}
async function restoreLaunchAgentInstallArtifacts(params: {
env: GatewayServiceEnv;
label: string;
plistPath: string;
snapshot: LaunchAgentInstallSnapshot;
}): Promise<void> {
await restoreLaunchAgentOwnedFile({
path: resolveLaunchAgentEnvFilePath(params.env, params.label),
contents: params.snapshot.envFileContents,
mode: LAUNCH_AGENT_ENV_FILE_MODE,
});
await restoreLaunchAgentOwnedFile({
path: resolveLaunchAgentEnvWrapperPath(params.env, params.label),
contents: params.snapshot.wrapperContents,
mode: LAUNCH_AGENT_ENV_WRAPPER_MODE,
});
for (const legacy of params.snapshot.legacy) {
await restoreLaunchAgentOwnedFile({
path: legacy.plistPath,
contents: legacy.contents,
mode: LAUNCH_AGENT_PLIST_MODE,
});
}
if (params.snapshot.plistContents === null) {
await fs.unlink(params.plistPath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
});
return;
}
await publishLaunchAgentPlist({
label: params.label,
plistPath: params.plistPath,
contents: params.snapshot.plistContents.toString("utf8"),
});
}
async function restoreLaunchAgentInstall(params: {
domain: string;
env: GatewayServiceEnv;
label: string;
plistPath: string;
snapshot: LaunchAgentInstallSnapshot;
}): Promise<void> {
const serviceTarget = `${params.domain}/${params.label}`;
// A failed bootstrap may leave no registered job. Restore files directly in
// that state; only a loaded replacement must be removed before rollback.
const currentState = await probeLaunchAgentState(serviceTarget);
if (currentState.state === "unknown") {
throw new Error(
`launchctl print could not determine whether ${serviceTarget} is loaded during LaunchAgent rollback: ${currentState.detail ?? "unknown error"}`,
);
}
if (currentState.state !== "not-loaded") {
const bootout = await execLaunchctl(["bootout", serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
}
}
await restoreLaunchAgentInstallArtifacts({
env: params.env,
label: params.label,
plistPath: params.plistPath,
snapshot: params.snapshot,
});
if (params.snapshot.loaded && params.snapshot.plistContents !== null) {
await bootstrapLaunchAgentOrThrow({
domain: params.domain,
serviceTarget,
plistPath: params.plistPath,
actionHint: "openclaw gateway start",
retryPendingTeardown: true,
});
}
for (const legacy of params.snapshot.legacy) {
if (!legacy.loaded || legacy.contents === null) {
continue;
}
await bootstrapLaunchAgentOrThrow({
domain: params.domain,
serviceTarget: `${params.domain}/${legacy.label}`,
plistPath: legacy.plistPath,
actionHint: "openclaw gateway start",
retryPendingTeardown: true,
});
}
}
async function deactivateLaunchAgentDefinition(domain: string, plistPath: string): Promise<void> {
for (const args of [
["bootout", domain, plistPath],
["unload", plistPath],
]) {
const result = await execLaunchctl(args);
if (result.code !== 0 && !isLaunchctlNotLoaded(result)) {
throw new Error(
`launchctl ${args[0]} failed during LaunchAgent install: ${formatLaunchctlResultDetail(result)}`,
);
}
}
}
async function activateLaunchAgent(params: {
env: GatewayServiceEnv;
plistPath: string;
snapshot: LaunchAgentInstallSnapshot;
}) {
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(params.env);
try {
// Recheck immediately before activation so a system daemon installed after
// the plist write cannot race us into two KeepAlive managers.
await assertNoSystemLaunchDaemonOwnership(label);
for (const legacy of params.snapshot.legacy) {
if (legacy.loaded) {
await deactivateLaunchAgentDefinition(domain, legacy.plistPath);
}
}
// Plist-form bootout reports EIO for a valid definition that was never loaded.
// The pre-publication snapshot is the authoritative cutover fact.
if (params.snapshot.loaded) {
await deactivateLaunchAgentDefinition(domain, params.plistPath);
}
// launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget: `${domain}/${label}`,
plistPath: params.plistPath,
actionHint: "openclaw gateway install --force",
retryPendingTeardown: true,
});
for (const legacy of params.snapshot.legacy) {
await fs.unlink(legacy.plistPath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
});
}
} catch (error) {
try {
await restoreLaunchAgentInstall({
domain,
env: params.env,
label,
plistPath: params.plistPath,
snapshot: params.snapshot,
});
} catch (rollbackError) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${detail}\nThe previous LaunchAgent supervision could not be restored.`, {
cause: rollbackError,
});
}
throw error;
}
}
export async function installLaunchAgent(
args: GatewayServiceInstallArgs,
): Promise<{ plistPath: string }> {
assertExternalLaunchAgentMutation(args.env, "install");
const targetPlistPath = resolveLaunchAgentPlistPath(args.env);
const previousContents = await readExistingLaunchAgentPlist(targetPlistPath);
const label = resolveLaunchAgentLabel(args.env);
const domain = resolveLaunchAgentGuiDomain();
// Plist, generated environment files, and launchd registration form one cutover.
// Capture every prior owner before publication so any later failure can restore it.
const legacy = await Promise.all(
resolveLegacyGatewayLaunchAgentLabels(args.env.OPENCLAW_PROFILE).map(async (legacyLabel) => {
const plistPath = resolveLaunchAgentPlistPathForLabel(args.env, legacyLabel);
const contents = await readExistingLaunchAgentPlist(plistPath);
return {
label: legacyLabel,
plistPath,
contents,
loaded: await snapshotLaunchAgentLoadedState(contents, `${domain}/${legacyLabel}`),
};
}),
);
const snapshot: LaunchAgentInstallSnapshot = {
plistContents: previousContents,
envFileContents: await readExistingLaunchAgentPlist(
resolveLaunchAgentEnvFilePath(args.env, label),
),
wrapperContents: await readExistingLaunchAgentPlist(
resolveLaunchAgentEnvWrapperPath(args.env, label),
),
legacy,
loaded: await snapshotLaunchAgentLoadedState(previousContents, `${domain}/${label}`),
};
let plistPath: string;
let stdoutPath: string;
try {
({ plistPath, stdoutPath } = await writeLaunchAgentPlist(args));
} catch (error) {
try {
await restoreLaunchAgentInstallArtifacts({
env: args.env,
label,
plistPath: targetPlistPath,
snapshot,
});
} catch (rollbackError) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${detail}\nThe previous LaunchAgent files could not be restored.`, {
cause: rollbackError,
});
}
throw error;
}
await activateLaunchAgent({
env: args.env,
plistPath,
snapshot,
});
// `bootstrap` already loads RunAtLoad agents. Avoid `kickstart -k` here:
// on slow macOS guests it SIGTERMs the freshly booted gateway and pushes the
// real listener startup past setup's health deadline.
writeFormattedLines(
args.stdout,
[
{ label: "Installed LaunchAgent", value: plistPath },
{ label: "Logs", value: stdoutPath },
],
{ leadingBlankLine: true },
);
return { plistPath };
}
+404
View File
@@ -0,0 +1,404 @@
/** LaunchAgent bootstrap recovery plus start and restart lifecycle controls. */
import { spawnSync } from "node:child_process";
import { formatPortDiagnostics } from "../infra/ports-format.js";
import { inspectPortUsage } from "../infra/ports-inspect.js";
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
import {
execLaunchctl,
formatLaunchctlResultDetail,
isLaunchctlNotLoaded,
} from "./launchd-exec.js";
import { resolveLaunchAgentLabel } from "./launchd-label.js";
import { scheduleDetachedLaunchdRestartHandoff } from "./launchd-restart-handoff.js";
import {
bootstrapLaunchAgentOrThrow,
isLaunchctlAlreadyLoaded,
isUnsupportedGuiDomain,
parseLaunchctlPrint,
readLaunchAgentRuntime,
resolveLaunchAgentGatewayContext,
resolveLaunchAgentGuiDomain,
} from "./launchd-runtime.js";
import {
resolveLaunchAgentPlistPath,
rewriteLaunchAgentPlistForRestart,
} from "./launchd-service-files.js";
import {
assertNoSystemLaunchDaemonOwnership,
isSystemLaunchDaemonOwnershipError,
} from "./launchd-system.js";
import { formatLine } from "./output.js";
import { createGatewayLifecycleMutationReporter } from "./service-mutation.js";
import type {
GatewayServiceControlArgs,
GatewayServiceEnv,
GatewayServiceRestartResult,
} from "./service-types.js";
const LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS = 2_000;
function readLaunchAgentPidForCleanupSync(serviceTarget: string): number {
const probe = spawnSync("launchctl", ["print", serviceTarget], {
encoding: "utf8",
timeout: LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS,
});
const result = {
stdout: probe.stdout ?? "",
stderr: probe.error?.message ?? probe.stderr ?? "",
code: probe.error ? 1 : (probe.status ?? 1),
};
if (result.code !== 0) {
throw new Error(`launchctl print failed: ${formatLaunchctlResultDetail(result)}`);
}
const pid = parseLaunchctlPrint(result.stdout || result.stderr || "").pid;
if (pid === undefined) {
throw new Error("launchctl print did not report a running pid");
}
return pid;
}
type LaunchAgentBootstrapRepairResult =
| { ok: true; status: "repaired" | "already-loaded" }
| {
ok: false;
status: "bootstrap-failed" | "kickstart-failed";
detail?: string;
}
| {
ok: false;
status: "system-launchdaemon-conflict" | "system-launchdaemon-unverifiable";
detail: string;
}
| { ok: false; status: "gui-session-unavailable"; detail: string; domain: string };
export async function repairLaunchAgentBootstrap(args: {
env?: Record<string, string | undefined>;
warn?: (message: string) => void;
}): Promise<LaunchAgentBootstrapRepairResult> {
const env = args.env ?? (process.env as Record<string, string | undefined>);
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(env);
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceTarget = `${domain}/${label}`;
try {
await assertNoSystemLaunchDaemonOwnership(label);
} catch (error) {
if (!isSystemLaunchDaemonOwnershipError(error)) {
throw error;
}
return {
ok: false,
status:
error.ownership.status === "unverifiable"
? "system-launchdaemon-unverifiable"
: "system-launchdaemon-conflict",
detail: error.message,
};
}
// Rewrite first so legacy inline environment secrets move into the private
// env file before the plist becomes world-readable for launchd.
const warn = args.warn ?? ((message: string) => console.warn(formatLine("Warning", message)));
await rewriteLaunchAgentPlistForRestart({ env, label, plistPath, warn });
await execLaunchctl(["enable", serviceTarget]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
let repairStatus: "repaired" | "already-loaded" = "repaired";
if (boot.code !== 0) {
const detail = (boot.stderr || boot.stdout).trim();
if (isUnsupportedGuiDomain(detail)) {
return {
ok: false,
status: "gui-session-unavailable",
detail,
domain,
};
}
if (!isLaunchctlAlreadyLoaded(boot)) {
return { ok: false, status: "bootstrap-failed", detail: detail || undefined };
}
repairStatus = "already-loaded";
}
if (repairStatus === "repaired") {
return { ok: true, status: repairStatus };
}
// Service is already bootstrapped. Only kickstart if it is not actively running —
// kickstarting a healthy running service causes unnecessary session disconnects.
const runtime = await readLaunchAgentRuntime(env);
if (runtime.status === "running") {
return { ok: true, status: repairStatus };
}
const kick = await execLaunchctl(["kickstart", serviceTarget]);
if (kick.code !== 0) {
return {
ok: false,
status: "kickstart-failed",
detail: (kick.stderr || kick.stdout).trim() || undefined,
};
}
return { ok: true, status: repairStatus };
}
type LaunchAgentRestoreResult = { loaded: true } | { loaded: false; detail: string };
function writeLaunchAgentActionLine(
stdout: NodeJS.WritableStream,
label: string,
value: string,
): void {
try {
stdout.write(`${formatLine(label, value)}\n`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") {
throw err;
}
}
}
async function ensureLaunchAgentLoadedAfterFailure(params: {
domain: string;
serviceTarget: string;
plistPath: string;
onMutation?: (mode: "enable" | "bootstrap") => void;
}): Promise<LaunchAgentRestoreResult> {
const probe = await execLaunchctl(["print", params.serviceTarget]);
if (probe.code === 0) {
return { loaded: true };
}
try {
await bootstrapLaunchAgentOrThrow({
domain: params.domain,
serviceTarget: params.serviceTarget,
plistPath: params.plistPath,
actionHint: "openclaw gateway start",
onMutation: params.onMutation,
});
return { loaded: true };
} catch (error) {
// A failed restore is not recoverable by launchd: the label is gone, so
// KeepAlive has nothing to respawn. Report it instead of dropping it.
return { loaded: false, detail: error instanceof Error ? error.message : String(error) };
}
}
function formatLaunchAgentLeftUnloadedError(params: {
domain: string;
serviceTarget: string;
plistPath: string;
failure: string;
restoreDetail: string;
}): string {
return [
params.failure,
`LaunchAgent ${params.serviceTarget} is not loaded and could not be restored: ${params.restoreDetail}`,
"The gateway is down and launchd has no job left to respawn it.",
`Fix: run \`openclaw gateway start\`, or \`launchctl bootstrap ${params.domain} ${params.plistPath}\`.`,
].join("\n");
}
export async function startLaunchAgent({
stdout,
env,
onMutation,
}: GatewayServiceControlArgs): Promise<void> {
const serviceEnv = env ?? (process.env as GatewayServiceEnv);
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(serviceEnv);
const plistPath = resolveLaunchAgentPlistPath(serviceEnv);
const serviceTarget = `${domain}/${label}`;
const reportMutation = createGatewayLifecycleMutationReporter(onMutation);
await assertNoSystemLaunchDaemonOwnership(label);
// Enable is an independent mutation; audit it even if the later launch fails.
const enable = await execLaunchctl(["enable", serviceTarget]);
const enabled = enable.code === 0;
if (enabled) {
reportMutation("enable");
}
const start = await execLaunchctl(["kickstart", serviceTarget]);
if (start.code === 0) {
reportMutation("kickstart");
} else if (isLaunchctlNotLoaded(start)) {
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,
plistPath,
actionHint: "openclaw gateway start",
onMutation: reportMutation,
skipEnable: enabled,
});
} else {
throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim());
}
writeLaunchAgentActionLine(stdout, "Started LaunchAgent", serviceTarget);
}
export async function restartLaunchAgent({
stdout,
env,
warn,
onMutation,
}: GatewayServiceControlArgs): Promise<GatewayServiceRestartResult> {
const serviceEnv = env ?? (process.env as GatewayServiceEnv);
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(serviceEnv);
const plistPath = resolveLaunchAgentPlistPath(serviceEnv);
const serviceTarget = `${domain}/${label}`;
const reportMutation = createGatewayLifecycleMutationReporter(onMutation);
await assertNoSystemLaunchDaemonOwnership(label);
// Restart requests issued from inside the managed gateway process tree need a
// detached handoff. A direct `kickstart -k` would terminate the caller before
// it can finish the restart command.
if (isCurrentProcessLaunchdServiceLabel(label)) {
const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({
env: serviceEnv,
label,
plistPath,
stdout,
warn,
});
const handoff = scheduleDetachedLaunchdRestartHandoff({
env: serviceEnv,
mode: plistReloadNeeded ? "reload" : "kickstart",
waitForPid: process.pid,
});
if (!handoff.ok) {
throw new Error(`launchd restart handoff failed: ${handoff.error}`);
}
reportMutation(plistReloadNeeded ? "handoff-reload" : "handoff-kickstart");
writeLaunchAgentActionLine(stdout, "Scheduled LaunchAgent restart", serviceTarget);
return { outcome: "scheduled" };
}
const { port: cleanupPort, probeHosts } = await resolveLaunchAgentGatewayContext(serviceEnv);
if (cleanupPort !== null) {
cleanStaleGatewayProcessesSync(cleanupPort, {
// Resolve after lsof captures its listener snapshot. A KeepAlive respawn
// during enumeration must be protected before candidate filtering/signals.
resolveProtectedPid: () => readLaunchAgentPidForCleanupSync(serviceTarget),
});
const diagnostics = await inspectPortUsage(cleanupPort, {
probeHosts,
}).catch(() => null);
if (diagnostics?.status === "busy") {
const runtime = await readLaunchAgentRuntime(serviceEnv);
const managedPid = runtime.pid;
// Only the current supervised PID may keep the port busy before a
// disruptive restart. Re-read after cleanup to close over a concurrent
// launchd respawn rather than trusting the protected pre-cleanup PID.
const ownedByLaunchAgent =
managedPid !== undefined &&
diagnostics.listeners.length > 0 &&
diagnostics.listeners.every((listener) => listener.pid === managedPid);
if (!ownedByLaunchAgent) {
throw new Error(
[
`gateway port ${cleanupPort} is busy but is not verifiably owned by LaunchAgent ${label}`,
...formatPortDiagnostics(diagnostics),
].join("\n"),
);
}
}
}
const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({
env: serviceEnv,
label,
plistPath,
stdout,
warn,
});
// `openclaw gateway restart` is an explicit operator request to bring the
// LaunchAgent back, so clear any persisted disabled state before restart.
const enable = await execLaunchctl(["enable", serviceTarget]);
if (enable.code === 0) {
reportMutation("enable");
}
if (plistReloadNeeded) {
const bootout = await execLaunchctl(["bootout", serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
}
if (bootout.code === 0) {
reportMutation("bootout");
}
try {
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,
plistPath,
actionHint: "openclaw gateway restart",
onMutation: reportMutation,
retryPendingTeardown: true,
});
} catch (error) {
// bootout already removed the job from the domain, so a failed bootstrap
// leaves the gateway down with no KeepAlive respawn to recover it. Restore
// the job before surfacing the original failure, as the kickstart path does.
const restored = await ensureLaunchAgentLoadedAfterFailure({
domain,
serviceTarget,
plistPath,
onMutation: reportMutation,
});
if (restored.loaded) {
throw error;
}
throw new Error(
formatLaunchAgentLeftUnloadedError({
domain,
serviceTarget,
plistPath,
failure: error instanceof Error ? error.message : String(error),
restoreDetail: restored.detail,
}),
{ cause: error },
);
}
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
return { outcome: "completed" };
}
const start = await execLaunchctl(["kickstart", "-k", serviceTarget]);
if (start.code === 0) {
reportMutation("kickstart");
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
return { outcome: "completed" };
}
if (!isLaunchctlNotLoaded(start)) {
const restored = await ensureLaunchAgentLoadedAfterFailure({
domain,
serviceTarget,
plistPath,
onMutation: reportMutation,
});
const failure = `launchctl kickstart failed: ${start.stderr || start.stdout}`.trim();
if (restored.loaded) {
throw new Error(failure);
}
throw new Error(
formatLaunchAgentLeftUnloadedError({
domain,
serviceTarget,
plistPath,
failure,
restoreDetail: restored.detail,
}),
);
}
// If the service was previously booted out, re-register the rewritten plist and retry.
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,
plistPath,
actionHint: "openclaw gateway restart",
onMutation: reportMutation,
});
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
return { outcome: "completed" };
}
+366
View File
@@ -0,0 +1,366 @@
/** launchctl state parsing, inspection, and bootstrap primitives. */
import fs from "node:fs/promises";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
import { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js";
import { sleep } from "../utils.js";
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
import {
execLaunchctl,
formatLaunchctlResultDetail,
isLaunchctlNotLoaded,
} from "./launchd-exec.js";
import { resolveLaunchAgentLabel } from "./launchd-label.js";
import { LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS } from "./launchd-plist.js";
import {
resolveLaunchAgentPlistPath,
readLaunchAgentProgramArguments,
} from "./launchd-service-files.js";
import {
formatSystemLaunchDaemonOwnershipSummary,
inspectSystemLaunchDaemonOwnership,
} from "./launchd-system.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import type { GatewayServiceEnv, GatewayServiceEnvArgs } from "./service-types.js";
// launchd reserves the label until the outgoing job actually exits, and it
// SIGKILLs that job once ExitTimeOut elapses. Bound the bootstrap retry by that
// same deadline plus slack so a drain-on-SIGTERM gateway cannot outlast it.
const LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_TIMEOUT_MS = (LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS + 10) * 1_000;
const LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_POLL_MS = 500;
export async function resolveLaunchAgentGatewayContext(env: GatewayServiceEnv): Promise<{
port: number | null;
probeHosts: readonly string[];
}> {
const command = await readLaunchAgentProgramArguments(env).catch(() => null);
const fromArgs = parseTcpPortFromArgs(command?.programArguments);
if (fromArgs !== null) {
return {
port: fromArgs,
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
const fromServiceEnv = parseTcpPort(command?.environment?.OPENCLAW_GATEWAY_PORT ?? "");
if (fromServiceEnv !== null) {
return {
port: fromServiceEnv,
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
return {
port: parseTcpPort(env.OPENCLAW_GATEWAY_PORT ?? ""),
probeHosts: await resolveGatewayServiceProbeHosts({ env, command }),
};
}
export function resolveLaunchAgentGuiDomain(): string {
if (typeof process.getuid !== "function") {
return "gui/501";
}
return `gui/${process.getuid()}`;
}
function throwBootstrapGuiSessionError(params: {
detail: string;
domain: string;
actionHint: string;
}) {
throw new Error(formatLaunchAgentGuiSessionError(params));
}
export function formatLaunchAgentGuiSessionError(params: {
detail: string;
domain: string;
actionHint: string;
}): string {
return [
`launchctl bootstrap failed: ${params.detail}`,
`LaunchAgent ${params.actionHint} requires a logged-in macOS GUI session for this user (${params.domain}).`,
"This usually means you are running from SSH/headless context or as the wrong user (including sudo).",
`Fix: sign in to the macOS desktop as the target user and rerun \`${params.actionHint}\`.`,
"For headless VM setups, enable auto-login for the target user so macOS creates the GUI session after boot.",
"Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway",
].join("\n");
}
export async function bootstrapLaunchAgentOrThrow(params: {
domain: string;
serviceTarget: string;
plistPath: string;
actionHint: string;
onMutation?: (mode: "enable" | "bootstrap") => void;
skipEnable?: boolean;
// Opt-in for callers that just issued `bootout` on this label. Only those can
// race a pending teardown, so start/install/recovery paths keep failing fast
// on an unrelated EIO instead of waiting out the teardown deadline.
retryPendingTeardown?: boolean;
}) {
// `disable` state survives bootout and plist rewrites; explicit start/repair
// paths must clear it before asking launchd to load the job again.
if (!params.skipEnable) {
const enable = await execLaunchctl(["enable", params.serviceTarget]);
if (enable.code === 0) {
params.onMutation?.("enable");
}
}
const teardownDeadline = Date.now() + LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_TIMEOUT_MS;
for (;;) {
const boot = await execLaunchctl(["bootstrap", params.domain, params.plistPath]);
if (boot.code === 0) {
params.onMutation?.("bootstrap");
return;
}
const detail = (boot.stderr || boot.stdout).trim();
if (isUnsupportedGuiDomain(detail)) {
throwBootstrapGuiSessionError({
detail,
domain: params.domain,
actionHint: params.actionHint,
});
}
if (isLaunchctlOperationAlreadyInProgress(detail)) {
const state = await probeLaunchAgentState(params.serviceTarget);
if (state.state === "running" || state.state === "stopped") {
params.onMutation?.("bootstrap");
return;
}
}
const remainingMs = teardownDeadline - Date.now();
if (
!params.retryPendingTeardown ||
!isLaunchctlBootstrapPendingTeardown(boot) ||
remainingMs <= 0
) {
throw new Error(`launchctl bootstrap failed: ${detail}`);
}
await sleep(Math.min(LAUNCH_AGENT_BOOTSTRAP_TEARDOWN_POLL_MS, remainingMs));
}
}
type LaunchctlPrintInfo = {
state?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
};
export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
const entries = parseKeyValueOutput(output, "=");
const info: LaunchctlPrintInfo = {};
const state = entries.state;
if (state) {
info.state = state;
}
const pidValue = entries.pid;
if (pidValue) {
const pid = parseStrictPositiveInteger(pidValue);
if (pid !== undefined) {
info.pid = pid;
}
}
const exitStatusValue = entries["last exit status"];
if (exitStatusValue) {
const status = parseStrictInteger(exitStatusValue);
if (status !== undefined) {
info.lastExitStatus = status;
}
}
const exitReason = entries["last exit reason"];
if (exitReason) {
info.lastExitReason = exitReason;
}
return info;
}
export function parseLaunchAgentEnabled(output: string, label: string): boolean {
const labelPrefix = `"${label}"`;
for (const line of output.split("\n")) {
const entry = line.trim();
if (!entry.startsWith(labelPrefix)) {
continue;
}
const state = entry.slice(labelPrefix.length).trim();
if (state === "=> enabled") {
return true;
}
if (state === "=> disabled") {
return false;
}
throw new Error(`launchctl print-disabled returned an unrecognized state for ${label}`);
}
// No persisted override means launchd uses the plist's normal enabled state.
return true;
}
export async function isLaunchAgentEnabled(args: GatewayServiceEnvArgs): Promise<boolean> {
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(args.env);
const res = await execLaunchctl(["print-disabled", domain]);
if (res.code !== 0) {
throw new Error(`launchctl print-disabled failed: ${formatLaunchctlResultDetail(res)}`);
}
return parseLaunchAgentEnabled(res.stdout || res.stderr || "", label);
}
export async function isLaunchAgentLoaded(args: GatewayServiceEnvArgs): Promise<boolean> {
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(args.env);
const res = await execLaunchctl(["print", `${domain}/${label}`]);
if (res.code === 0) {
return true;
}
if (isLaunchctlNotLoaded(res)) {
return false;
}
throw new Error(`launchctl print failed: ${formatLaunchctlResultDetail(res)}`);
}
export async function launchAgentPlistExists(env: GatewayServiceEnv): Promise<boolean> {
try {
const plistPath = resolveLaunchAgentPlistPath(env);
await fs.access(plistPath);
return true;
} catch {
return false;
}
}
export async function readLaunchAgentRuntime(
env: Record<string, string | undefined>,
): Promise<GatewayServiceRuntime> {
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(env);
const [res, systemOwnership] = await Promise.all([
execLaunchctl(["print", `${domain}/${label}`]),
inspectSystemLaunchDaemonOwnership(label, { scanInstalledPlists: false }),
]);
if (systemOwnership.status !== "absent") {
return {
status: "unknown",
detail: formatSystemLaunchDaemonOwnershipSummary(systemOwnership),
systemLaunchDaemon: {
status: systemOwnership.status,
serviceTarget: systemOwnership.serviceTarget,
...(systemOwnership.status === "installed" ? { plistPath: systemOwnership.plistPath } : {}),
},
};
}
if (res.code !== 0) {
const plistExists = await launchAgentPlistExists(env);
const detail = (res.stderr || res.stdout).trim() || undefined;
const missingGuiSession = plistExists && isUnsupportedGuiDomain(detail ?? "");
return {
status: "unknown",
detail,
...(plistExists
? { missingSupervision: true, ...(missingGuiSession ? { missingGuiSession } : {}) }
: { missingUnit: true }),
};
}
const parsed = parseLaunchctlPrint(res.stdout || res.stderr || "");
const plistExists = await launchAgentPlistExists(env);
const state = normalizeLowercaseStringOrEmpty(parsed.state);
const status = state === "running" || parsed.pid ? "running" : state ? "stopped" : "unknown";
return {
status,
state: parsed.state,
pid: parsed.pid,
lastExitStatus: parsed.lastExitStatus,
lastExitReason: parsed.lastExitReason,
cachedLabel: !plistExists,
};
}
export function isLaunchctlAlreadyLoaded(res: {
stdout: string;
stderr: string;
code: number;
}): boolean {
const detail = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout);
return res.code === 130 || detail.includes("already exists in domain");
}
export function isUnsupportedGuiDomain(detail: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(detail);
return (
normalized.includes("domain does not support specified action") ||
normalized.includes("could not find domain for user gui") ||
normalized.includes("bootstrap failed: 125")
);
}
function isLaunchctlOperationAlreadyInProgress(detail: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(detail);
return (
normalized.includes("operation already in progress") ||
normalized.includes("bootstrap failed: 37")
);
}
function isLaunchctlBootstrapPendingTeardown(res: {
stdout: string;
stderr: string;
code: number;
}): boolean {
// `bootout` returns once launchd accepts the request, not once the job is gone,
// so bootstrapping the same label mid-teardown answers EIO. The plist is valid
// here, so this is a timing conflict to retry rather than a real I/O fault.
//
// launchd answers the same EIO for a label that is simply still registered
// ("already exists in domain"). That job is not tearing down, so waiting for a
// teardown that never comes only delays the failure.
if (isLaunchctlAlreadyLoaded(res)) {
return false;
}
const normalized = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout);
return normalized.includes("bootstrap failed: 5") || normalized.includes("input/output error");
}
type LaunchAgentProbeResult =
| { state: "running" }
| { state: "stopped" }
| { state: "not-loaded" }
| { state: "unknown"; detail?: string };
export async function probeLaunchAgentState(
serviceTarget: string,
): Promise<LaunchAgentProbeResult> {
// `launchctl print` output is not a stable API, so this is only a stop
// confirmation probe. Unknown output falls back to bootout instead of success.
const probe = await execLaunchctl(["print", serviceTarget]);
if (probe.code !== 0) {
if (isLaunchctlNotLoaded(probe)) {
return { state: "not-loaded" };
}
return {
state: "unknown",
detail: formatLaunchctlResultDetail(probe) || undefined,
};
}
const runtime = parseLaunchctlPrint(probe.stdout || probe.stderr || "");
if (
normalizeLowercaseStringOrEmpty(runtime.state) === "running" ||
(typeof runtime.pid === "number" && runtime.pid > 1)
) {
return { state: "running" };
}
return { state: "stopped" };
}
export async function waitForLaunchAgentStopped(
serviceTarget: string,
): Promise<LaunchAgentProbeResult> {
let lastUnknown: LaunchAgentProbeResult | null = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
const probe = await probeLaunchAgentState(serviceTarget);
if (probe.state === "stopped" || probe.state === "not-loaded") {
return probe;
}
if (probe.state === "unknown") {
lastUnknown = probe;
}
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
}
return lastUnknown ?? { state: "running" };
}
+439
View File
@@ -0,0 +1,439 @@
/** LaunchAgent plist, environment-file, and atomic publication ownership. */
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeEnvVarKey } from "../infra/host-env-security.js";
import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayServiceDescription } from "./constants.js";
import { resolveLaunchAgentLabel } from "./launchd-label.js";
import {
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
buildLaunchAgentPlist as buildLaunchAgentPlistImpl,
readLaunchAgentProgramArgumentsFromFile,
} from "./launchd-plist.js";
import { assertNoSystemLaunchDaemonOwnership } from "./launchd-system.js";
import { formatLine, toPosixPath } from "./output.js";
import { resolveDaemonHomeDir, resolveGatewayStateDir } from "./paths.js";
import { resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
import type {
GatewayServiceCommandConfig,
GatewayServiceEnv,
GatewayServiceInstallArgs,
} from "./service-types.js";
const LAUNCH_AGENT_DIR_MODE = 0o755;
// launchd rejects user LaunchAgent plists without group/other read access on
// current macOS. Secrets stay in the separate 0600 environment file.
export const LAUNCH_AGENT_PLIST_MODE = 0o644;
const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700;
export const LAUNCH_AGENT_ENV_FILE_MODE = 0o600;
export const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700;
const LAUNCH_AGENT_ENV_DIR_NAME = "service-env";
const LAUNCH_AGENT_STDERR_PATH = "/dev/null";
export function resolveLaunchAgentPlistPathForLabel(
env: Record<string, string | undefined>,
label: string,
): string {
const home = toPosixPath(resolveDaemonHomeDir(env));
return path.posix.join(home, "Library", "LaunchAgents", `${label}.plist`);
}
function resolveLaunchAgentEnvDir(env: GatewayServiceEnv): string {
return path.join(resolveGatewayStateDir(env), LAUNCH_AGENT_ENV_DIR_NAME);
}
export function resolveLaunchAgentEnvFilePath(env: GatewayServiceEnv, label: string): string {
return path.join(resolveLaunchAgentEnvDir(env), `${label}.env`);
}
export function resolveLaunchAgentEnvWrapperPath(env: GatewayServiceEnv, label: string): string {
return path.join(resolveLaunchAgentEnvDir(env), `${label}-env-wrapper.sh`);
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function collectLaunchAgentEnvironmentEntries(
environment: GatewayServiceEnv | undefined,
): Array<[string, string]> {
const entries: Array<[string, string]> = [];
for (const [rawKey, rawValue] of Object.entries(environment ?? {})) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
const value = rawValue?.trim();
if (!key || !value) {
continue;
}
entries.push([key, value]);
}
return entries.toSorted(([left], [right]) => left.localeCompare(right));
}
function buildLaunchAgentEnvironmentFile(entries: Array<[string, string]>): string {
return [
"# Generated by OpenClaw. Do not edit while the gateway service is installed.",
...entries.map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
"",
].join("\n");
}
function buildLaunchAgentEnvironmentWrapper(): string {
return `#!/bin/sh
set -eu
env_file="$1"
shift
if [ -f "$env_file" ]; then
. "$env_file"
fi
exec "$@"
`;
}
async function resolveLaunchAgentEnvironmentWrapperOverwriteWarnings(params: {
wrapperPath: string;
generatedWrapper: string;
}): Promise<string[]> {
const existingWrapper = await fs.readFile(params.wrapperPath, "utf8").catch(() => null);
if (existingWrapper === null || existingWrapper === params.generatedWrapper) {
return [];
}
return [
`Existing generated LaunchAgent env wrapper at ${params.wrapperPath} contains custom behavior and will be overwritten; move custom behavior to openclaw gateway install --wrapper <path> or OPENCLAW_WRAPPER.`,
];
}
function writeLaunchAgentOverwriteWarnings(
stdout: NodeJS.WritableStream | undefined,
warn: ((message: string) => void) | undefined,
warnings: readonly string[],
): void {
for (const warning of warnings) {
if (warn) {
warn(warning);
continue;
}
if (!stdout) {
continue;
}
stdout.write(`${formatLine("Warning", warning)}\n`);
}
}
function isLaunchAgentEnvironmentWrapperArgs(params: {
programArguments: string[];
envFilePath: string;
wrapperPath: string;
}): boolean {
return (
(params.programArguments[0] === params.wrapperPath &&
params.programArguments[1] === params.envFilePath) ||
(params.programArguments[0] === LAUNCH_AGENT_ENV_WRAPPER_SHELL &&
params.programArguments[1] === params.wrapperPath &&
params.programArguments[2] === params.envFilePath)
);
}
async function prepareLaunchAgentProgramArguments(params: {
env: GatewayServiceEnv;
label: string;
programArguments: string[];
environment: GatewayServiceEnv | undefined;
stdout?: NodeJS.WritableStream;
warn?: (message: string) => void;
}): Promise<{
programArguments: string[];
inlineEnvironment?: GatewayServiceEnv;
}> {
const entries = collectLaunchAgentEnvironmentEntries(params.environment);
if (entries.length === 0) {
return { programArguments: params.programArguments };
}
// Environment values with secrets live in an owner-only env file instead of
// inline plist XML, which can be harder to rotate and audit.
const envDir = resolveLaunchAgentEnvDir(params.env);
const envFilePath = resolveLaunchAgentEnvFilePath(params.env, params.label);
const wrapperPath = resolveLaunchAgentEnvWrapperPath(params.env, params.label);
const generatedWrapper = buildLaunchAgentEnvironmentWrapper();
await ensureSecureDirectory(envDir, LAUNCH_AGENT_PRIVATE_DIR_MODE);
await fs.writeFile(envFilePath, buildLaunchAgentEnvironmentFile(entries), {
encoding: "utf8",
mode: LAUNCH_AGENT_ENV_FILE_MODE,
});
await fs.chmod(envFilePath, LAUNCH_AGENT_ENV_FILE_MODE).catch(() => undefined);
const overwriteWarnings = await resolveLaunchAgentEnvironmentWrapperOverwriteWarnings({
wrapperPath,
generatedWrapper,
});
writeLaunchAgentOverwriteWarnings(params.stdout, params.warn, overwriteWarnings);
await fs.writeFile(wrapperPath, generatedWrapper, {
encoding: "utf8",
mode: LAUNCH_AGENT_ENV_WRAPPER_MODE,
});
await fs.chmod(wrapperPath, LAUNCH_AGENT_ENV_WRAPPER_MODE).catch(() => undefined);
if (
isLaunchAgentEnvironmentWrapperArgs({
programArguments: params.programArguments,
envFilePath,
wrapperPath,
})
) {
return { programArguments: params.programArguments };
}
return {
programArguments: [
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
wrapperPath,
envFilePath,
...params.programArguments,
],
};
}
export function resolveLaunchAgentPlistPath(env: GatewayServiceEnv): string {
const label = resolveLaunchAgentLabel(env);
return resolveLaunchAgentPlistPathForLabel(env, label);
}
function resolveLaunchAgentEnvironmentReadOptions(env: GatewayServiceEnv, label: string) {
return {
expectedEnvironmentWrapperPath: resolveLaunchAgentEnvWrapperPath(env, label),
expectedEnvironmentFilePath: resolveLaunchAgentEnvFilePath(env, label),
generatedEnvironmentLabel: label,
};
}
export async function readLaunchAgentProgramArguments(
env: GatewayServiceEnv,
): Promise<GatewayServiceCommandConfig | null> {
const label = resolveLaunchAgentLabel(env);
const plistPath = resolveLaunchAgentPlistPath(env);
return readLaunchAgentProgramArgumentsFromFile(
plistPath,
resolveLaunchAgentEnvironmentReadOptions(env, label),
);
}
function buildLaunchAgentPlist({
label = GATEWAY_LAUNCH_AGENT_LABEL,
comment,
programArguments,
workingDirectory,
stdoutPath,
stderrPath,
environment,
}: {
label?: string;
comment?: string;
programArguments: string[];
workingDirectory?: string;
stdoutPath: string;
stderrPath: string;
environment?: Record<string, string | undefined>;
}): string {
return buildLaunchAgentPlistImpl({
label,
comment,
programArguments,
workingDirectory,
stdoutPath,
stderrPath,
environment,
});
}
async function ensureLaunchAgentPlistReadable(plistPath: string): Promise<void> {
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
}
export async function readExistingLaunchAgentPlist(plistPath: string): Promise<Buffer | null> {
try {
return await fs.readFile(plistPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
export async function publishLaunchAgentPlist(params: {
label: string;
plistPath: string;
contents: string;
}): Promise<void> {
const previousContents = await readExistingLaunchAgentPlist(params.plistPath);
const temporaryPath = `${params.plistPath}.openclaw-${randomUUID()}.tmp`;
await fs.writeFile(temporaryPath, params.contents, {
encoding: "utf8",
flag: "wx",
mode: LAUNCH_AGENT_PLIST_MODE,
});
try {
// The temporary filename does not end in .plist, so launchd cannot discover
// it before the final ownership check and atomic publication.
await assertNoSystemLaunchDaemonOwnership(params.label);
await fs.rename(temporaryPath, params.plistPath);
try {
await assertNoSystemLaunchDaemonOwnership(params.label);
} catch (ownershipError) {
try {
if (previousContents === null) {
await fs.unlink(params.plistPath);
} else {
const rollbackPath = `${params.plistPath}.openclaw-${randomUUID()}.rollback`;
try {
await fs.writeFile(rollbackPath, previousContents, {
flag: "wx",
mode: LAUNCH_AGENT_PLIST_MODE,
});
await fs.rename(rollbackPath, params.plistPath);
} finally {
await fs.unlink(rollbackPath).catch(() => undefined);
}
}
} catch (rollbackError) {
const ownershipDetail =
ownershipError instanceof Error ? ownershipError.message : String(ownershipError);
throw new Error(
`${ownershipDetail}\nThe previous LaunchAgent plist at ${params.plistPath} could not be restored.`,
{ cause: rollbackError },
);
}
throw ownershipError;
}
} finally {
await fs.unlink(temporaryPath).catch(() => undefined);
}
await ensureLaunchAgentPlistReadable(params.plistPath);
}
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 forbiddenMode = dirMode === LAUNCH_AGENT_PRIVATE_DIR_MODE ? 0o077 : 0o022;
const tightenedMode = mode & ~forbiddenMode;
if (tightenedMode !== mode) {
await fs.chmod(targetPath, tightenedMode);
}
} catch {
// Best effort: keep install working even if chmod/stat is unavailable.
}
}
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 async function writeLaunchAgentPlist({
env,
programArguments,
workingDirectory,
environment,
description,
stdout,
warn,
}: GatewayServiceInstallArgs): Promise<{ plistPath: string; stdoutPath: string }> {
const label = resolveLaunchAgentLabel(env);
await assertNoSystemLaunchDaemonOwnership(label);
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
await ensureSecureDirectory(logDir);
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
const home = toPosixPath(resolveDaemonHomeDir(env));
const libraryDir = path.posix.join(home, "Library");
await ensureSecureDirectory(home);
await ensureSecureDirectory(libraryDir);
await ensureSecureDirectory(path.dirname(plistPath));
await ensureLaunchAgentEnvironmentDirectories(environment);
const prepared = await prepareLaunchAgentProgramArguments({
env,
label,
programArguments,
environment,
stdout,
warn,
});
const serviceDescription = resolveGatewayServiceDescription({ env, description });
const plist = buildLaunchAgentPlist({
label,
comment: serviceDescription,
programArguments: prepared.programArguments,
workingDirectory,
stdoutPath,
stderrPath: LAUNCH_AGENT_STDERR_PATH,
environment: prepared.inlineEnvironment,
});
await publishLaunchAgentPlist({ label, plistPath, contents: plist });
return { plistPath, stdoutPath };
}
export async function rewriteLaunchAgentPlistForRestart({
env,
label,
plistPath,
stdout,
warn,
}: {
env: GatewayServiceEnv;
label: string;
plistPath: string;
stdout?: NodeJS.WritableStream;
warn?: (message: string) => void;
}): Promise<boolean> {
const existing = await readLaunchAgentProgramArgumentsFromFile(
plistPath,
resolveLaunchAgentEnvironmentReadOptions(env, label),
);
if (!existing?.programArguments.length) {
return false;
}
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
await ensureSecureDirectory(logDir);
const serviceDescription = resolveGatewayServiceDescription({
env,
});
// Restart rewrites must retire install provenance from legacy plists instead
// of copying it into the next canonical definition.
const canonicalEnvironment = {
...existing.environment,
OPENCLAW_SERVICE_VERSION: undefined,
};
const prepared = await prepareLaunchAgentProgramArguments({
env,
label,
programArguments: existing.programArguments,
environment: canonicalEnvironment,
stdout,
warn,
});
const plist = buildLaunchAgentPlist({
label,
comment: serviceDescription,
programArguments: prepared.programArguments,
workingDirectory: existing.workingDirectory,
stdoutPath,
stderrPath: LAUNCH_AGENT_STDERR_PATH,
environment: prepared.inlineEnvironment,
});
const previousPlist = await fs.readFile(plistPath, "utf8").catch(() => "");
if (previousPlist === plist) {
await ensureLaunchAgentPlistReadable(plistPath);
return false;
}
await publishLaunchAgentPlist({ label, plistPath, contents: plist });
return true;
}
+209
View File
@@ -0,0 +1,209 @@
/** LaunchAgent stop semantics and in-service maintenance parking. */
import { formatPortDiagnostics } from "../infra/ports-format.js";
import { inspectPortUsage } from "../infra/ports-inspect.js";
import { probePortUsage } from "../infra/ports-probe.js";
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
import { sleep } from "../utils.js";
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
import {
execLaunchctl,
formatLaunchctlResultDetail,
isLaunchctlNotLoaded,
} from "./launchd-exec.js";
import { resolveLaunchAgentLabel } from "./launchd-label.js";
import { LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS } from "./launchd-plist.js";
import { scheduleDetachedLaunchdMaintenancePark } from "./launchd-restart-handoff.js";
import {
resolveLaunchAgentGatewayContext,
resolveLaunchAgentGuiDomain,
waitForLaunchAgentStopped,
} from "./launchd-runtime.js";
import { formatLine } from "./output.js";
import { createGatewayLifecycleMutationReporter } from "./service-mutation.js";
import type { GatewayServiceControlArgs, GatewayServiceEnv } from "./service-types.js";
const LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS = LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS * 1_000;
const LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS = 100;
async function bootoutLaunchAgentOrThrow(params: {
serviceTarget: string;
warning: string;
stdout: NodeJS.WritableStream;
onMutation?: () => void;
}): Promise<void> {
const bootout = await execLaunchctl(["bootout", params.serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(
`${params.warning}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`,
);
}
params.onMutation?.();
params.stdout.write(`${formatLine("Warning", params.warning)}\n`);
}
async function waitForGatewayPortRelease(
port: number,
probeHosts: readonly string[],
): Promise<boolean> {
const deadline = Date.now() + LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(Math.min(LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS, deadline - Date.now()));
const status = await probePortUsage(port, probeHosts);
if (status === "free") {
return true;
}
}
return false;
}
async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise<void> {
const { port, probeHosts } = await resolveLaunchAgentGatewayContext(env);
if (port === null) {
return;
}
cleanStaleGatewayProcessesSync(port);
const diagnostics = await inspectPortUsage(port, {
probeHosts,
}).catch(() => null);
if (diagnostics?.status !== "busy") {
return;
}
if (await waitForGatewayPortRelease(port, probeHosts)) {
return;
}
throw new Error(
[
`gateway port ${port} is still busy after LaunchAgent stop`,
...formatPortDiagnostics(diagnostics),
].join("\n"),
);
}
export async function stopLaunchAgent({
stdout,
env,
disable: persistDisable,
onMutation,
}: GatewayServiceControlArgs): Promise<void> {
const serviceEnv = env ?? (process.env as GatewayServiceEnv);
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(serviceEnv);
const serviceTarget = `${domain}/${label}`;
const reportMutation = createGatewayLifecycleMutationReporter(onMutation);
if (
isCurrentProcessLaunchdServiceLabel(label, process.env, { allowConfiguredLabelFallback: false })
) {
throw new Error(
`Refusing to stop LaunchAgent ${label} from inside the same launchd service; run this command from an external shell.`,
);
}
if (!persistDisable) {
// Default: bootout only. Removes the job from the current launchd domain without
// persisting a disable, so KeepAlive auto-recovery survives future crashes and
// `openclaw gateway start` re-enables cleanly without a manual `launchctl enable`.
const bootout = await execLaunchctl(["bootout", serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
}
reportMutation("bootout");
await assertGatewayPortReleasedAfterStop(serviceEnv);
stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`);
return;
}
// --disable: persistently suppress KeepAlive/RunAtLoad before stopping.
// Without this, launchd can relaunch the process as soon as `stop` exits.
const disableResult = await execLaunchctl(["disable", serviceTarget]);
if (disableResult.code !== 0) {
await bootoutLaunchAgentOrThrow({
serviceTarget,
stdout,
warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disableResult)}`,
onMutation: () => reportMutation("disable-bootout"),
});
await assertGatewayPortReleasedAfterStop(serviceEnv);
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
return;
}
reportMutation("disable");
// `launchctl stop` targets the plain label (not the fully-qualified service target).
const stop = await execLaunchctl(["stop", label]);
if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) {
await bootoutLaunchAgentOrThrow({
serviceTarget,
stdout,
warning: `launchctl stop failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(stop)}`,
onMutation: () => reportMutation("disable-bootout"),
});
await assertGatewayPortReleasedAfterStop(serviceEnv);
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
return;
}
reportMutation("disable-stop");
const stopState = await waitForLaunchAgentStopped(serviceTarget);
if (stopState.state !== "stopped" && stopState.state !== "not-loaded") {
const warning =
stopState.state === "unknown"
? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}`
: "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded";
await bootoutLaunchAgentOrThrow({
serviceTarget,
stdout,
warning,
onMutation: () => reportMutation("disable-bootout"),
});
await assertGatewayPortReleasedAfterStop(serviceEnv);
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
return;
}
await assertGatewayPortReleasedAfterStop(serviceEnv);
stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`);
}
export async function parkCurrentLaunchAgentForMaintenance(
params: {
env?: GatewayServiceEnv;
} = {},
): Promise<boolean> {
const serviceEnv = params.env ?? (process.env as GatewayServiceEnv);
const domain = resolveLaunchAgentGuiDomain();
const label = resolveLaunchAgentLabel(serviceEnv);
if (
!isCurrentProcessLaunchdServiceLabel(label, process.env, {
allowConfiguredLabelFallback: false,
})
) {
return false;
}
const serviceTarget = `${domain}/${label}`;
// Disable before exit so KeepAlive cannot spawn a replacement before the
// detached handoff can boot the current job out of launchd.
const disable = await execLaunchctl(["disable", serviceTarget]);
if (disable.code !== 0) {
throw new Error(
`launchctl disable failed while parking ${serviceTarget}: ${formatLaunchctlResultDetail(disable)}`,
);
}
const handoff = scheduleDetachedLaunchdMaintenancePark({
env: serviceEnv,
waitForPid: process.pid,
});
const handoffError = !handoff.ok
? handoff.error
: (await handoff.value)
? undefined
: "helper failed to spawn";
if (handoffError) {
const rollback = await execLaunchctl(["enable", serviceTarget]);
const rollbackDetail =
rollback.code === 0
? "restored launchd enable state"
: `launchctl enable rollback failed: ${formatLaunchctlResultDetail(rollback)}`;
throw new Error(`launchd maintenance park handoff failed: ${handoffError}; ${rollbackDetail}`);
}
return true;
}
+264
View File
@@ -0,0 +1,264 @@
/** Discovery and shutdown of stale OpenClaw launchd updater jobs. */
import path from "node:path";
import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
import {
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
resolveGatewayLaunchAgentLabel,
} from "./constants.js";
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
import { execLaunchctl } from "./launchd-exec.js";
import { assertValidLaunchAgentLabel } from "./launchd-label.js";
import { readLaunchAgentProgramArgumentsFromFile } from "./launchd-plist.js";
import { resolveLaunchAgentGuiDomain } from "./launchd-runtime.js";
import { resolveLaunchAgentPlistPathForLabel } from "./launchd-service-files.js";
const OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX = "ai.openclaw.update.";
const OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN = /^ai\.openclaw\.manual-update\.\d+$/;
const OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN =
/^ai\.openclaw\.[A-Za-z0-9._-]+\.update\.[A-Za-z0-9._-]+$/;
const OPENCLAW_DIRECT_CLI_NAMES = new Set(["openclaw", "openclaw.mjs"]);
const OPENCLAW_NODE_RUNTIME_NAMES = new Set(["bun", "bun.exe", "node", "node.exe"]);
const OPENCLAW_SCRIPT_NAMES = new Set(["openclaw.mjs"]);
export type StaleOpenClawUpdateLaunchdJob = {
label: string;
pid?: number;
lastExitStatus?: number;
};
type OpenClawUpdateLaunchdLabelCandidate = {
label: string;
requiresMetadata: boolean;
};
function normalizeOpenClawUpdateLaunchdLabel(label: unknown): string | null {
if (typeof label !== "string") {
return null;
}
const trimmed = label.trim();
if (trimmed.startsWith(OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX)) {
return trimmed;
}
// Manual update jobs include a timestamp-like suffix and should be cleaned up
// without matching arbitrary ai.openclaw labels.
return OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed) ? trimmed : null;
}
function normalizeOpenClawUpdateLaunchdLabelCandidate(
label: unknown,
): OpenClawUpdateLaunchdLabelCandidate | null {
const normalized = normalizeOpenClawUpdateLaunchdLabel(label);
if (normalized) {
return { label: normalized, requiresMetadata: false };
}
if (typeof label !== "string") {
return null;
}
const trimmed = label.trim();
return OPENCLAW_PROFILE_UPDATE_LAUNCHD_LABEL_PATTERN.test(trimmed)
? { label: trimmed, requiresMetadata: true }
: null;
}
function isCurrentGatewayLaunchdLabel(label: string, env: NodeJS.ProcessEnv): boolean {
const gatewayProfileLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
if (label === gatewayProfileLabel) {
return true;
}
if (
env.OPENCLAW_SERVICE_MARKER?.trim() !== GATEWAY_SERVICE_MARKER ||
env.OPENCLAW_SERVICE_KIND?.trim() !== GATEWAY_SERVICE_KIND
) {
return false;
}
const configuredLabel = env.OPENCLAW_LAUNCHD_LABEL?.trim();
return Boolean(configuredLabel && label === configuredLabel);
}
function resolveCurrentOpenClawUpdateLaunchdJobLabel(
env: NodeJS.ProcessEnv = process.env,
): OpenClawUpdateLaunchdLabelCandidate | null {
for (const label of [
env.LAUNCH_JOB_LABEL,
env.LAUNCH_JOB_NAME,
env.XPC_SERVICE_NAME,
env.OPENCLAW_LAUNCHD_LABEL,
]) {
const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label);
if (candidate) {
if (isCurrentGatewayLaunchdLabel(candidate.label, env)) {
continue;
}
return candidate;
}
}
return null;
}
export function parseLaunchctlListOpenClawUpdateJobs(
output: string,
): StaleOpenClawUpdateLaunchdJob[] {
return parseLaunchctlListOpenClawUpdateJobCandidates(output)
.filter((job) => !job.requiresMetadata)
.map(({ requiresMetadata: _requiresMetadata, ...job }) => job);
}
function parseLaunchctlListOpenClawUpdateJobCandidates(
output: string,
): Array<StaleOpenClawUpdateLaunchdJob & OpenClawUpdateLaunchdLabelCandidate> {
const jobs: Array<StaleOpenClawUpdateLaunchdJob & OpenClawUpdateLaunchdLabelCandidate> = [];
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) {
continue;
}
const parts = line.split(/\s+/);
const [pidRaw, statusRaw, ...labelParts] = parts;
const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(labelParts.join(" "));
if (!candidate) {
continue;
}
const pid = pidRaw === "-" ? undefined : parseStrictPositiveInteger(pidRaw ?? "");
const lastExitStatus = parseStrictInteger(statusRaw ?? "");
jobs.push({
label: candidate.label,
requiresMetadata: candidate.requiresMetadata,
...(pid !== undefined ? { pid } : {}),
...(lastExitStatus !== undefined ? { lastExitStatus } : {}),
});
}
return jobs.toSorted((a, b) => a.label.localeCompare(b.label));
}
function hasOpenClawUpdateLaunchdMarker(env: Record<string, string | undefined> | undefined) {
return env?.OPENCLAW_UPDATE_RUN_HANDOFF?.trim() === "1";
}
function isOpenClawUpdateCommandPrefix(programArguments: string[], updateIndex: number): boolean {
if (updateIndex === 1) {
const cliName = path.basename(programArguments[0] ?? "").toLowerCase();
return OPENCLAW_DIRECT_CLI_NAMES.has(cliName);
}
if (updateIndex !== 2) {
return false;
}
const runtimeName = path.basename(programArguments[0] ?? "").toLowerCase();
const entryName = path.basename(programArguments[1] ?? "").toLowerCase();
return OPENCLAW_NODE_RUNTIME_NAMES.has(runtimeName) && OPENCLAW_SCRIPT_NAMES.has(entryName);
}
function isOpenClawUpdateProgramArguments(programArguments: string[] | undefined): boolean {
if (!Array.isArray(programArguments) || programArguments.length === 0) {
return false;
}
const updateIndex = programArguments.findIndex((arg) => arg.trim() === "update");
if (updateIndex < 0 || !programArguments.slice(updateIndex + 1).includes("--yes")) {
return false;
}
return (
isOpenClawUpdateCommandPrefix(programArguments, updateIndex) &&
!programArguments.some((arg) => arg.trim() === "gateway")
);
}
async function isLaunchdJobConfirmedOpenClawUpdater(params: {
label: string;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
const plistPath = resolveLaunchAgentPlistPathForLabel(params.env, params.label);
const command = await readLaunchAgentProgramArgumentsFromFile(plistPath);
return (
hasOpenClawUpdateLaunchdMarker(command?.environment) ||
isOpenClawUpdateProgramArguments(command?.programArguments)
);
}
export async function findStaleOpenClawUpdateLaunchdJobs(
env: NodeJS.ProcessEnv = process.env,
): Promise<StaleOpenClawUpdateLaunchdJob[]> {
if (process.platform !== "darwin") {
return [];
}
const result = await execLaunchctl(["list"]);
if (result.code !== 0) {
return [];
}
// Never report the active gateway label as stale even when a wrapper exposes
// update-like launchd metadata through the current environment.
const jobs: StaleOpenClawUpdateLaunchdJob[] = [];
for (const job of parseLaunchctlListOpenClawUpdateJobCandidates(result.stdout)) {
if (isCurrentGatewayLaunchdLabel(job.label, env)) {
continue;
}
if (
job.requiresMetadata &&
!(await isLaunchdJobConfirmedOpenClawUpdater({ label: job.label, env }))
) {
continue;
}
jobs.push({
label: job.label,
...(job.pid !== undefined ? { pid: job.pid } : {}),
...(job.lastExitStatus !== undefined ? { lastExitStatus: job.lastExitStatus } : {}),
});
}
return jobs;
}
async function disableOpenClawUpdateLaunchdJobCandidate(params: {
candidate: OpenClawUpdateLaunchdLabelCandidate;
env: NodeJS.ProcessEnv;
trustCurrentEnvMarker: boolean;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
if (
params.candidate.requiresMetadata &&
!(
(params.trustCurrentEnvMarker && hasOpenClawUpdateLaunchdMarker(params.env)) ||
(await isLaunchdJobConfirmedOpenClawUpdater({
label: params.candidate.label,
env: params.env,
}))
)
) {
return false;
}
const serviceTarget = `${resolveLaunchAgentGuiDomain()}/${assertValidLaunchAgentLabel(params.candidate.label)}`;
const result = await execLaunchctl(["disable", serviceTarget]);
return result.code === 0;
}
export async function disableOpenClawUpdateLaunchdJob(
label: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<boolean> {
const candidate = normalizeOpenClawUpdateLaunchdLabelCandidate(label);
if (!candidate) {
return false;
}
return await disableOpenClawUpdateLaunchdJobCandidate({
candidate,
env,
trustCurrentEnvMarker: false,
});
}
export async function disableCurrentOpenClawUpdateLaunchdJob(
env: NodeJS.ProcessEnv = process.env,
): Promise<boolean> {
const candidate = resolveCurrentOpenClawUpdateLaunchdJobLabel(env);
if (!candidate) {
return false;
}
return await disableOpenClawUpdateLaunchdJobCandidate({
candidate,
env,
// Detached handoffs preserve the configured label, so only launchd-backed
// current-process identity may turn the ambient marker into proof.
trustCurrentEnvMarker: isCurrentProcessLaunchdServiceLabel(candidate.label, env, {
allowConfiguredLabelFallback: false,
}),
});
}
+27 -2010
View File
File diff suppressed because it is too large Load Diff