refactor: share daemon launchd and path helpers

This commit is contained in:
Peter Steinberger 2026-03-14 00:15:13 +00:00
parent 2d39c50ee6
commit 49cbcea429
2 changed files with 68 additions and 63 deletions

View File

@ -7,6 +7,7 @@ import {
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "./constants.js";
import { resolveHomeDir } from "./paths.js";
import { execSchtasks } from "./schtasks-exec.js";
export type ExtraGatewayService = {
@ -49,14 +50,6 @@ export function renderGatewayServiceCleanupHints(
}
}
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
if (!home) {
throw new Error("Missing HOME");
}
return home;
}
type Marker = (typeof EXTRA_MARKERS)[number];
function detectMarker(content: string): Marker | null {

View File

@ -120,6 +120,58 @@ function resolveGuiDomain(): string {
return `gui/${process.getuid()}`;
}
function throwBootstrapGuiSessionError(params: {
detail: string;
domain: string;
actionHint: string;
}) {
throw new Error(
[
`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}\`.`,
"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;
}) {
await execLaunchctl(["enable", params.serviceTarget]);
const boot = await execLaunchctl(["bootstrap", params.domain, params.plistPath]);
if (boot.code === 0) {
return;
}
const detail = (boot.stderr || boot.stdout).trim();
if (isUnsupportedGuiDomain(detail)) {
throwBootstrapGuiSessionError({
detail,
domain: params.domain,
actionHint: params.actionHint,
});
}
throw new Error(`launchctl bootstrap failed: ${detail}`);
}
async function ensureSecureDirectory(targetPath: string): Promise<void> {
await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE });
try {
@ -414,23 +466,12 @@ export async function installLaunchAgent({
await execLaunchctl(["bootout", domain, plistPath]);
await execLaunchctl(["unload", plistPath]);
// launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.
await execLaunchctl(["enable", `${domain}/${label}`]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) {
const detail = (boot.stderr || boot.stdout).trim();
if (isUnsupportedGuiDomain(detail)) {
throw new Error(
[
`launchctl bootstrap failed: ${detail}`,
`LaunchAgent install requires a logged-in macOS GUI session for this user (${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 `openclaw gateway install --force`.",
"Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway",
].join("\n"),
);
}
throw new Error(`launchctl bootstrap failed: ${detail}`);
}
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget: `${domain}/${label}`,
plistPath,
actionHint: "openclaw gateway install --force",
});
// `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 onboarding's health deadline.
@ -469,25 +510,13 @@ export async function restartLaunchAgent({
if (!handoff.ok) {
throw new Error(`launchd restart handoff failed: ${handoff.detail ?? "unknown error"}`);
}
try {
stdout.write(`${formatLine("Scheduled LaunchAgent restart", serviceTarget)}\n`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") {
throw err;
}
}
writeLaunchAgentActionLine(stdout, "Scheduled LaunchAgent restart", serviceTarget);
return { outcome: "scheduled" };
}
const start = await execLaunchctl(["kickstart", "-k", serviceTarget]);
if (start.code === 0) {
try {
stdout.write(`${formatLine("Restarted LaunchAgent", serviceTarget)}\n`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") {
throw err;
}
}
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
return { outcome: "completed" };
}
@ -496,34 +525,17 @@ export async function restartLaunchAgent({
}
// If the service was previously booted out, re-register the plist and retry.
await execLaunchctl(["enable", serviceTarget]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) {
const detail = (boot.stderr || boot.stdout).trim();
if (isUnsupportedGuiDomain(detail)) {
throw new Error(
[
`launchctl bootstrap failed: ${detail}`,
`LaunchAgent restart requires a logged-in macOS GUI session for this user (${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 `openclaw gateway restart`.",
"Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway",
].join("\n"),
);
}
throw new Error(`launchctl bootstrap failed: ${detail}`);
}
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,
plistPath,
actionHint: "openclaw gateway restart",
});
const retry = await execLaunchctl(["kickstart", "-k", serviceTarget]);
if (retry.code !== 0) {
throw new Error(`launchctl kickstart failed: ${retry.stderr || retry.stdout}`.trim());
}
try {
stdout.write(`${formatLine("Restarted LaunchAgent", serviceTarget)}\n`);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code !== "EPIPE") {
throw err;
}
}
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
return { outcome: "completed" };
}