mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
bcf342903f
* refactor(gateway): distinguish pending node handlers * refactor(approvals): distinguish button presentation builder * refactor(daemon): distinguish service home resolver
2014 lines
67 KiB
TypeScript
2014 lines
67 KiB
TypeScript
/** macOS LaunchAgent installer, runtime inspection, and lifecycle controls. */
|
|
import { spawnSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
|
import { normalizeEnvVarKey } from "../infra/host-env-security.js";
|
|
import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
|
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 { parseTcpPort, parseTcpPortFromArgs } from "../infra/tcp-port.js";
|
|
import { sleep } from "../utils.js";
|
|
import {
|
|
GATEWAY_LAUNCH_AGENT_LABEL,
|
|
GATEWAY_SERVICE_KIND,
|
|
GATEWAY_SERVICE_MARKER,
|
|
resolveGatewayServiceDescription,
|
|
resolveGatewayLaunchAgentLabel,
|
|
resolveLegacyGatewayLaunchAgentLabels,
|
|
} from "./constants.js";
|
|
import { resolveGatewayServiceProbeHosts } from "./gateway-service-probe-hosts.js";
|
|
import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js";
|
|
import {
|
|
execLaunchctl,
|
|
formatLaunchctlResultDetail,
|
|
isLaunchctlNotLoaded,
|
|
} from "./launchd-exec.js";
|
|
import { assertValidLaunchAgentLabel, resolveLaunchAgentLabel } from "./launchd-label.js";
|
|
import {
|
|
LAUNCH_AGENT_ENV_WRAPPER_SHELL,
|
|
buildLaunchAgentPlist as buildLaunchAgentPlistImpl,
|
|
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
|
|
readLaunchAgentProgramArgumentsFromFile,
|
|
} from "./launchd-plist.js";
|
|
import {
|
|
scheduleDetachedLaunchdMaintenancePark,
|
|
scheduleDetachedLaunchdRestartHandoff,
|
|
} from "./launchd-restart-handoff.js";
|
|
import {
|
|
assertNoSystemLaunchDaemonOwnership,
|
|
formatSystemLaunchDaemonOwnershipSummary,
|
|
inspectSystemLaunchDaemonOwnership,
|
|
isSystemLaunchDaemonOwnershipError,
|
|
} from "./launchd-system.js";
|
|
import { formatLine, toPosixPath, writeFormattedLines } from "./output.js";
|
|
import { resolveDaemonHomeDir, resolveGatewayStateDir } from "./paths.js";
|
|
import { resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
|
|
import { parseKeyValueOutput } from "./runtime-parse.js";
|
|
import { createGatewayLifecycleMutationReporter } from "./service-mutation.js";
|
|
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
|
import type {
|
|
GatewayServiceCommandConfig,
|
|
GatewayServiceControlArgs,
|
|
GatewayServiceEnv,
|
|
GatewayServiceEnvArgs,
|
|
GatewayServiceInstallArgs,
|
|
GatewayServiceManageArgs,
|
|
GatewayServiceRestartResult,
|
|
} from "./service-types.js";
|
|
|
|
export { isLaunchctlNotLoaded } from "./launchd-exec.js";
|
|
export { resolveLaunchAgentLabel } from "./launchd-label.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.
|
|
const LAUNCH_AGENT_PLIST_MODE = 0o644;
|
|
const LAUNCH_AGENT_PRIVATE_DIR_MODE = 0o700;
|
|
const LAUNCH_AGENT_ENV_FILE_MODE = 0o600;
|
|
const LAUNCH_AGENT_ENV_WRAPPER_MODE = 0o700;
|
|
const LAUNCH_AGENT_ENV_DIR_NAME = "service-env";
|
|
const LAUNCH_AGENT_STDERR_PATH = "/dev/null";
|
|
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"]);
|
|
const LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS = LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS * 1_000;
|
|
const LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS = 100;
|
|
// 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;
|
|
const LAUNCHCTL_PROTECTED_PID_TIMEOUT_MS = 2_000;
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function resolveLaunchAgentEnvFilePath(env: GatewayServiceEnv, label: string): string {
|
|
return path.join(resolveLaunchAgentEnvDir(env), `${label}.env`);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = `${resolveGuiDomain()}/${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,
|
|
}),
|
|
});
|
|
}
|
|
|
|
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 }),
|
|
};
|
|
}
|
|
|
|
function resolveGuiDomain(): 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");
|
|
}
|
|
|
|
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 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));
|
|
}
|
|
}
|
|
|
|
async function ensureLaunchAgentPlistReadable(plistPath: string): Promise<void> {
|
|
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 = resolveGuiDomain();
|
|
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 = resolveGuiDomain();
|
|
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 = resolveGuiDomain();
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 };
|
|
|
|
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 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 = resolveGuiDomain();
|
|
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 };
|
|
}
|
|
|
|
export async function uninstallLaunchAgent({
|
|
env,
|
|
stdout,
|
|
}: GatewayServiceManageArgs): Promise<void> {
|
|
assertExternalLaunchAgentMutation(env, "uninstall");
|
|
const domain = resolveGuiDomain();
|
|
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 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");
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
type LaunchAgentProbeResult =
|
|
| { state: "running" }
|
|
| { state: "stopped" }
|
|
| { state: "not-loaded" }
|
|
| { state: "unknown"; detail?: string };
|
|
|
|
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" };
|
|
}
|
|
|
|
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" };
|
|
}
|
|
|
|
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 = resolveGuiDomain();
|
|
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 = resolveGuiDomain();
|
|
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;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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 = resolveGuiDomain();
|
|
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 = resolveGuiDomain();
|
|
// 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 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
type LaunchAgentRestoreResult = { loaded: true } | { loaded: false; detail: string };
|
|
|
|
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 = resolveGuiDomain();
|
|
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 = resolveGuiDomain();
|
|
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" };
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|