Merge 041a55855fd83854fd25ccc9f4ba95dc798fe38e into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
Benedikt Schackenberg 2026-03-21 05:00:17 +03:00 committed by GitHub
commit cc373f563a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 113 additions and 8 deletions

View File

@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
listOutput: "",
printOutput: "",
bootstrapError: "",
bootstrapFailuresRemaining: Infinity,
loadError: "",
kickstartError: "",
kickstartFailuresRemaining: 0,
dirs: new Set<string>(),
@ -74,9 +76,13 @@ vi.mock("./exec-file.js", () => ({
if (call[0] === "print") {
return { stdout: state.printOutput, stderr: "", code: 0 };
}
if (call[0] === "bootstrap" && state.bootstrapError) {
if (call[0] === "bootstrap" && state.bootstrapError && state.bootstrapFailuresRemaining > 0) {
state.bootstrapFailuresRemaining -= 1;
return { stdout: "", stderr: state.bootstrapError, code: 1 };
}
if (call[0] === "load" && state.loadError) {
return { stdout: "", stderr: state.loadError, code: 1 };
}
if (call[0] === "kickstart" && state.kickstartError && state.kickstartFailuresRemaining > 0) {
state.kickstartFailuresRemaining -= 1;
return { stdout: "", stderr: state.kickstartError, code: 1 };
@ -152,6 +158,8 @@ beforeEach(() => {
state.listOutput = "";
state.printOutput = "";
state.bootstrapError = "";
state.bootstrapFailuresRemaining = Infinity;
state.loadError = "";
state.kickstartError = "";
state.kickstartFailuresRemaining = 0;
state.dirs.clear();
@ -438,6 +446,7 @@ describe("launchd install", () => {
it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => {
state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action";
state.loadError = "Could not find domain for user gui: 1000";
const env = createDefaultLaunchdEnv();
let message = "";
try {
@ -452,6 +461,53 @@ describe("launchd install", () => {
expect(message).toContain("logged-in macOS GUI session");
expect(message).toContain("wrong user (including sudo)");
expect(message).toContain("https://docs.openclaw.ai/gateway");
// Verify that `load -w` was attempted as a fallback before giving up.
expect(state.launchctlCalls.some((c) => c[0] === "load")).toBe(true);
});
it("falls back to launchctl load when gui domain is unavailable", async () => {
state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action";
// loadError is empty → launchctl load succeeds
const env = createDefaultLaunchdEnv();
const stdout = new PassThrough();
await installLaunchAgent({
env,
stdout,
programArguments: defaultProgramArguments,
});
expect(state.launchctlCalls.some((c) => c[0] === "load" && c[1] === "-w")).toBe(true);
});
it("retries bootstrap after bootout when service is already registered", async () => {
state.bootstrapError = "Bootstrap failed: 5: Input/output error";
state.bootstrapFailuresRemaining = 1; // first bootstrap fails, retry succeeds
const env = createDefaultLaunchdEnv();
const stdout = new PassThrough();
await installLaunchAgent({
env,
stdout,
programArguments: defaultProgramArguments,
});
// Verify bootout was called between the two bootstrap attempts.
const bootoutAfterBootstrap = state.launchctlCalls.findIndex(
(c, i) =>
c[0] === "bootout" &&
i > state.launchctlCalls.findIndex((b) => b[0] === "bootstrap"),
);
expect(bootoutAfterBootstrap).toBeGreaterThanOrEqual(0);
});
it("falls back to launchctl load for 'Could not find domain' error from issue #8619", async () => {
state.bootstrapError = "Could not find domain for user gui: 1000";
// loadError is empty → launchctl load succeeds
const env = createDefaultLaunchdEnv();
const stdout = new PassThrough();
await installLaunchAgent({
env,
stdout,
programArguments: defaultProgramArguments,
});
expect(state.launchctlCalls.some((c) => c[0] === "load" && c[1] === "-w")).toBe(true);
});
it("surfaces generic bootstrap failures without GUI-specific guidance", async () => {

View File

@ -201,12 +201,47 @@ async function bootstrapLaunchAgentOrThrow(params: {
return;
}
const detail = (boot.stderr || boot.stdout).trim();
if (isUnsupportedGuiDomain(detail)) {
throwBootstrapGuiSessionError({
detail,
domain: params.domain,
actionHint: params.actionHint,
});
// Track the most relevant error detail across retries so that the final
// error message shown to the user reflects the actual failure, not a stale
// "already bootstrapped" message from an earlier attempt.
let effectiveDetail = detail;
// If the service is already registered (e.g. re-install without prior uninstall),
// bootout the stale registration and retry once.
if (isAlreadyBootstrapped(detail)) {
await execLaunchctl(["bootout", params.domain, params.plistPath]);
const retry = await execLaunchctl(["bootstrap", params.domain, params.plistPath]);
if (retry.code === 0) {
return;
}
const retryDetail = (retry.stderr || retry.stdout).trim();
effectiveDetail = retryDetail;
if (isUnsupportedGuiDomain(retryDetail)) {
// Fall through to the legacy load fallback below.
} else if (!isAlreadyBootstrapped(retryDetail)) {
throw new Error(`launchctl bootstrap failed: ${retryDetail}`);
}
}
// If the gui/ domain is unavailable (SSH, headless, or sudo), try the
// deprecated-but-universal `launchctl load` as a last resort before giving up.
if (isUnsupportedGuiDomain(effectiveDetail) || isAlreadyBootstrapped(effectiveDetail)) {
const load = await execLaunchctl(["load", "-w", params.plistPath]);
if (load.code === 0) {
return;
}
const loadDetail = (load.stderr || load.stdout).trim();
// Only show GUI-session guidance when the failure is actually gui-domain related
// and the load fallback didn't reveal a different, more actionable error.
if (isUnsupportedGuiDomain(effectiveDetail)) {
const isLoadDifferentError = loadDetail && !isUnsupportedGuiDomain(loadDetail);
throwBootstrapGuiSessionError({
detail: isLoadDifferentError ? `${loadDetail} (bootstrap: ${effectiveDetail})` : effectiveDetail,
domain: params.domain,
actionHint: params.actionHint,
});
}
throw new Error(`launchctl load failed: ${loadDetail || effectiveDetail}`);
}
throw new Error(`launchctl bootstrap failed: ${detail}`);
}
@ -444,7 +479,21 @@ function isUnsupportedGuiDomain(detail: string): boolean {
const normalized = detail.toLowerCase();
return (
normalized.includes("domain does not support specified action") ||
normalized.includes("bootstrap failed: 125")
normalized.includes("bootstrap failed: 125") ||
normalized.includes("could not find domain for")
);
}
function isAlreadyBootstrapped(detail: string): boolean {
const normalized = detail.toLowerCase();
// Match explicit "already loaded" / "already bootstrapped" messages.
// We intentionally do NOT match the generic "bootstrap failed: 5"
// (Input/output error) because that code also fires for malformed or
// unreadable plists, and treating those as "already loaded" would mask
// real bootstrap failures.
return (
normalized.includes("already loaded") ||
normalized.includes("already bootstrapped")
);
}