Skip to content

Commit f5868ad

Browse files
authored
fix(daemon): refresh launchd plist before restart bootstrap (#71421)
1 parent cc09925 commit f5868ad

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai
8080
- 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.
8181
- 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.
8282
- 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.
83+
- macOS Gateway: rewrite stale LaunchAgent plists before restart fallback bootstrap, matching install repair behavior when `gateway restart` has to re-register launchd. Thanks @maybegeeker.
8384
- TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris.
8485
- 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.
8586
- 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.

src/daemon/launchd.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const state = vi.hoisted(() => ({
3939
dirModes: new Map<string, number>(),
4040
files: new Map<string, string>(),
4141
fileModes: new Map<string, number>(),
42+
fileWrites: [] as Array<{ path: string; data: string }>,
4243
}));
4344
const launchdRestartHandoffState = vi.hoisted(() => ({
4445
isCurrentProcessLaunchdServiceLabel: vi.fn<(label: string) => boolean>(() => false),
@@ -242,6 +243,7 @@ vi.mock("node:fs/promises", async () => {
242243
writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => {
243244
const key = p;
244245
state.files.set(key, data);
246+
state.fileWrites.push({ path: key, data });
245247
state.dirs.add(key.split("/").slice(0, -1).join("/"));
246248
state.fileModes.set(key, opts?.mode ?? 0o666);
247249
}),
@@ -274,6 +276,7 @@ beforeEach(() => {
274276
state.dirModes.clear();
275277
state.files.clear();
276278
state.fileModes.clear();
279+
state.fileWrites.length = 0;
277280
cleanStaleGatewayProcessesSync.mockReset();
278281
cleanStaleGatewayProcessesSync.mockReturnValue([]);
279282
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset();
@@ -472,6 +475,48 @@ describe("launchd install", () => {
472475
expect(plist).toContain(`<integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>`);
473476
});
474477

478+
it("rewrites the plist before bootstrap during restart fallback", async () => {
479+
const env = createDefaultLaunchdEnv();
480+
const plistPath = resolveLaunchAgentPlistPath(env);
481+
state.serviceLoaded = false;
482+
state.kickstartError = "Could not find service";
483+
state.kickstartCode = 113;
484+
state.kickstartFailuresRemaining = 1;
485+
state.files.set(
486+
plistPath,
487+
[
488+
'<?xml version="1.0" encoding="UTF-8"?>',
489+
'<plist version="1.0">',
490+
" <dict>",
491+
" <key>Label</key>",
492+
" <string>ai.openclaw.gateway</string>",
493+
" <key>ProgramArguments</key>",
494+
" <array>",
495+
" <string>node</string>",
496+
" <string>gateway.js</string>",
497+
" </array>",
498+
" </dict>",
499+
"</plist>",
500+
].join("\n"),
501+
);
502+
503+
await restartLaunchAgent({
504+
env,
505+
stdout: new PassThrough(),
506+
});
507+
508+
const plist = state.files.get(plistPath) ?? "";
509+
expect(plist).toContain("<key>StandardOutPath</key>");
510+
expect(plist).toContain("<key>StandardErrorPath</key>");
511+
expect(plist).toContain("<key>KeepAlive</key>");
512+
expect(plist).toContain("<string>node</string>");
513+
const rewriteIndex = state.fileWrites.findIndex((write) => write.path === plistPath);
514+
const bootstrapIndex = state.launchctlCalls.findIndex((call) => call[0] === "bootstrap");
515+
expect(rewriteIndex).toBeGreaterThanOrEqual(0);
516+
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
517+
expect(rewriteIndex).toBeLessThan(bootstrapIndex);
518+
});
519+
475520
it("tightens writable bits on launch agent dirs and plist", async () => {
476521
const env = createDefaultLaunchdEnv();
477522
state.dirs.add(env.HOME!);

src/daemon/launchd.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,40 @@ export async function installLaunchAgent(
601601
return { plistPath };
602602
}
603603

604+
async function rewriteLaunchAgentPlistForRestart({
605+
env,
606+
label,
607+
plistPath,
608+
}: {
609+
env: GatewayServiceEnv;
610+
label: string;
611+
plistPath: string;
612+
}): Promise<void> {
613+
const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath);
614+
if (!existing?.programArguments.length) {
615+
return;
616+
}
617+
618+
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
619+
await ensureSecureDirectory(logDir);
620+
621+
const serviceDescription = resolveGatewayServiceDescription({
622+
env,
623+
environment: existing.environment,
624+
});
625+
const plist = buildLaunchAgentPlist({
626+
label,
627+
comment: serviceDescription,
628+
programArguments: existing.programArguments,
629+
workingDirectory: existing.workingDirectory,
630+
stdoutPath,
631+
stderrPath,
632+
environment: existing.environment,
633+
});
634+
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });
635+
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
636+
}
637+
604638
async function ensureLaunchAgentLoadedAfterFailure(params: {
605639
domain: string;
606640
serviceTarget: string;
@@ -669,6 +703,7 @@ export async function restartLaunchAgent({
669703
}
670704

671705
// If the service was previously booted out, re-register the plist and retry.
706+
await rewriteLaunchAgentPlistForRestart({ env: serviceEnv, label, plistPath });
672707
await bootstrapLaunchAgentOrThrow({
673708
domain,
674709
serviceTarget,

0 commit comments

Comments
 (0)