fix(daemon): refresh launchd plist before restart bootstrap (#71421)

This commit is contained in:
Vincent Koc
2026-04-24 22:59:21 -07:00
committed by GitHub
parent cc0992564b
commit f5868ad1f8
3 changed files with 81 additions and 0 deletions

View File

@@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai
- Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21.
- macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc.
- macOS Gateway: tolerate launchctl bootstrap's already-loaded exit during restart fallback and use non-killing kickstart after bootstrap, avoiding a second race that can unload the LaunchAgent. Fixes #41934. Thanks @zerone0x.
- macOS Gateway: rewrite stale LaunchAgent plists before restart fallback bootstrap, matching install repair behavior when `gateway restart` has to re-register launchd. Thanks @maybegeeker.
- TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris.
- WhatsApp/TTS: preserve `audioAsVoice` through shared media payload sends and the WhatsApp outbound adapter, so `[[audio_as_voice]]` reply payloads keep their voice-note intent when routed through `sendPayload`. Fixes #66053. Thanks @masatohoshino.
- Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai.

View File

@@ -39,6 +39,7 @@ const state = vi.hoisted(() => ({
dirModes: new Map<string, number>(),
files: new Map<string, string>(),
fileModes: new Map<string, number>(),
fileWrites: [] as Array<{ path: string; data: string }>,
}));
const launchdRestartHandoffState = vi.hoisted(() => ({
isCurrentProcessLaunchdServiceLabel: vi.fn<(label: string) => boolean>(() => false),
@@ -242,6 +243,7 @@ vi.mock("node:fs/promises", async () => {
writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => {
const key = p;
state.files.set(key, data);
state.fileWrites.push({ path: key, data });
state.dirs.add(key.split("/").slice(0, -1).join("/"));
state.fileModes.set(key, opts?.mode ?? 0o666);
}),
@@ -274,6 +276,7 @@ beforeEach(() => {
state.dirModes.clear();
state.files.clear();
state.fileModes.clear();
state.fileWrites.length = 0;
cleanStaleGatewayProcessesSync.mockReset();
cleanStaleGatewayProcessesSync.mockReturnValue([]);
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset();
@@ -472,6 +475,48 @@ describe("launchd install", () => {
expect(plist).toContain(`<integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>`);
});
it("rewrites the plist before bootstrap during restart fallback", async () => {
const env = createDefaultLaunchdEnv();
const plistPath = resolveLaunchAgentPlistPath(env);
state.serviceLoaded = false;
state.kickstartError = "Could not find service";
state.kickstartCode = 113;
state.kickstartFailuresRemaining = 1;
state.files.set(
plistPath,
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
" <dict>",
" <key>Label</key>",
" <string>ai.openclaw.gateway</string>",
" <key>ProgramArguments</key>",
" <array>",
" <string>node</string>",
" <string>gateway.js</string>",
" </array>",
" </dict>",
"</plist>",
].join("\n"),
);
await restartLaunchAgent({
env,
stdout: new PassThrough(),
});
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>StandardOutPath</key>");
expect(plist).toContain("<key>StandardErrorPath</key>");
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<string>node</string>");
const rewriteIndex = state.fileWrites.findIndex((write) => write.path === plistPath);
const bootstrapIndex = state.launchctlCalls.findIndex((call) => call[0] === "bootstrap");
expect(rewriteIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(rewriteIndex).toBeLessThan(bootstrapIndex);
});
it("tightens writable bits on launch agent dirs and plist", async () => {
const env = createDefaultLaunchdEnv();
state.dirs.add(env.HOME!);

View File

@@ -601,6 +601,40 @@ export async function installLaunchAgent(
return { plistPath };
}
async function rewriteLaunchAgentPlistForRestart({
env,
label,
plistPath,
}: {
env: GatewayServiceEnv;
label: string;
plistPath: string;
}): Promise<void> {
const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath);
if (!existing?.programArguments.length) {
return;
}
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
await ensureSecureDirectory(logDir);
const serviceDescription = resolveGatewayServiceDescription({
env,
environment: existing.environment,
});
const plist = buildLaunchAgentPlist({
label,
comment: serviceDescription,
programArguments: existing.programArguments,
workingDirectory: existing.workingDirectory,
stdoutPath,
stderrPath,
environment: existing.environment,
});
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
}
async function ensureLaunchAgentLoadedAfterFailure(params: {
domain: string;
serviceTarget: string;
@@ -669,6 +703,7 @@ export async function restartLaunchAgent({
}
// If the service was previously booted out, re-register the plist and retry.
await rewriteLaunchAgentPlistForRestart({ env: serviceEnv, label, plistPath });
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,