mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
f0d6cc4ade
commit
3d707a9b96
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user