fix(daemon): stabilize LaunchAgent restart and proxy env passthrough (#27276)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b08797a995
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Frank Yang
2026-02-25 23:40:48 -08:00
committed by GitHub
parent 96c7702526
commit b975711429
7 changed files with 334 additions and 5 deletions

View File

@@ -5,12 +5,14 @@ import {
isLaunchAgentListed,
parseLaunchctlPrint,
repairLaunchAgentBootstrap,
restartLaunchAgent,
resolveLaunchAgentPlistPath,
} from "./launchd.js";
const state = vi.hoisted(() => ({
launchctlCalls: [] as string[][],
listOutput: "",
printOutput: "",
bootstrapError: "",
dirs: new Set<string>(),
files: new Map<string, string>(),
@@ -35,6 +37,9 @@ vi.mock("./exec-file.js", () => ({
if (call[0] === "list") {
return { stdout: state.listOutput, stderr: "", code: 0 };
}
if (call[0] === "print") {
return { stdout: state.printOutput, stderr: "", code: 0 };
}
if (call[0] === "bootstrap" && state.bootstrapError) {
return { stdout: "", stderr: state.bootstrapError, code: 1 };
}
@@ -71,6 +76,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
beforeEach(() => {
state.launchctlCalls.length = 0;
state.listOutput = "";
state.printOutput = "";
state.bootstrapError = "";
state.dirs.clear();
state.files.clear();
@@ -179,6 +185,86 @@ describe("launchd install", () => {
expect(plist).toContain(`<string>${tmpDir}</string>`);
});
it("writes crash-only KeepAlive policy with throttle interval", async () => {
const env = createDefaultLaunchdEnv();
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
});
const plistPath = resolveLaunchAgentPlistPath(env);
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<key>SuccessfulExit</key>");
expect(plist).toContain("<false/>");
expect(plist).toContain("<key>ThrottleInterval</key>");
expect(plist).toContain("<integer>5</integer>");
});
it("restarts LaunchAgent with bootout-bootstrap-kickstart order", async () => {
const env = createDefaultLaunchdEnv();
await restartLaunchAgent({
env,
stdout: new PassThrough(),
});
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const bootoutIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootout" && c[1] === `${domain}/${label}`,
);
const bootstrapIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
);
const kickstartIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === `${domain}/${label}`,
);
expect(bootoutIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(kickstartIndex).toBeGreaterThanOrEqual(0);
expect(bootoutIndex).toBeLessThan(bootstrapIndex);
expect(bootstrapIndex).toBeLessThan(kickstartIndex);
});
it("waits for previous launchd pid to exit before bootstrapping", async () => {
const env = createDefaultLaunchdEnv();
state.printOutput = ["state = running", "pid = 4242"].join("\n");
const killSpy = vi.spyOn(process, "kill");
killSpy
.mockImplementationOnce(() => true)
.mockImplementationOnce(() => {
const err = new Error("no such process") as NodeJS.ErrnoException;
err.code = "ESRCH";
throw err;
});
vi.useFakeTimers();
try {
const restartPromise = restartLaunchAgent({
env,
stdout: new PassThrough(),
});
await vi.advanceTimersByTimeAsync(250);
await restartPromise;
expect(killSpy).toHaveBeenCalledWith(4242, 0);
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const bootoutIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "bootout" && c[1] === `${domain}/${label}`,
);
const bootstrapIndex = state.launchctlCalls.findIndex((c) => c[0] === "bootstrap");
expect(bootoutIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(bootoutIndex).toBeLessThan(bootstrapIndex);
} finally {
vi.useRealTimers();
killSpy.mockRestore();
}
});
it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => {
state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action";
const env = createDefaultLaunchdEnv();