diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af3f8ac908..4698a75f343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 6cb97c266a0..da70f09e2ad 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -39,6 +39,7 @@ const state = vi.hoisted(() => ({ dirModes: new Map(), files: new Map(), fileModes: new Map(), + 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(`${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}`); }); + 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, + [ + '', + '', + " ", + " Label", + " ai.openclaw.gateway", + " ProgramArguments", + " ", + " node", + " gateway.js", + " ", + " ", + "", + ].join("\n"), + ); + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + const plist = state.files.get(plistPath) ?? ""; + expect(plist).toContain("StandardOutPath"); + expect(plist).toContain("StandardErrorPath"); + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("node"); + 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!); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 2d87a584aff..a79eb3da4b8 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -601,6 +601,40 @@ export async function installLaunchAgent( return { plistPath }; } +async function rewriteLaunchAgentPlistForRestart({ + env, + label, + plistPath, +}: { + env: GatewayServiceEnv; + label: string; + plistPath: string; +}): Promise { + 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,