fix(daemon): reconcile macOS LaunchAgent supervision state#72616
Conversation
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟠 PATH hijack risk: launchctl executed without absolute path in execLaunchctl
DescriptionThe macOS LaunchAgent management code executes
Vulnerable code: const file = isWindows ? (process.env.ComSpec ?? "cmd.exe") : "launchctl";
return await execFileUtf8(file, fileArgs, isWindows ? { windowsHide: true } : {});This risk is amplified by the change in CLI behavior that calls LaunchAgent recovery earlier/more often on macOS, increasing the frequency of this execution path. RecommendationInvoke Example fix: import path from "node:path";
const launchctlPath = process.platform === "darwin" ? "/bin/launchctl" : "launchctl";
return await execFileUtf8(
launchctlPath,
args,
{
// Optionally restrict env to avoid PATH-based resolution entirely
env: {
...process.env,
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
},
}
);At minimum, use Analyzed PR: #72616 at commit Last updated on: 2026-04-27T05:41:10Z |
Greptile SummaryThis PR detects and repairs the macOS LaunchAgent "split-brain" state — where the plist is installed and the gateway listener/RPC is healthy but
Confidence Score: 3/5The PR contains a P1 logic issue in the restart path that can leave a launchd respawn loop when an unmanaged gateway process is still alive on the port. One P1 finding (launchd respawn loop due to priority inversion in src/cli/daemon-cli/lifecycle.ts — priority inversion of launchd repair vs. unmanaged restart needs a pre-termination guard for existing unmanaged PIDs. Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/cli/daemon-cli/lifecycle.ts
Line: 204-215
Comment:
**Launchd respawn loop when unmanaged process holds the port**
When `onNotLoaded` fires and an unmanaged gateway process is already listening on the port (the split-brain state with pid 4200 in the updated test), calling `recoverInstalledLaunchAgent` first runs `launchctl bootstrap` + `kickstart -k`. `kickstart` returns 0 as soon as launchd spawns the new process, regardless of whether it stays alive. The new launchd-managed process then fails to bind the port (still held by the unmanaged process) and exits. launchd detects the exit and immediately respawns — creating a respawn loop.
The unmanaged process (pid 4200) is never signaled because `signalVerifiedGatewayPidSync` is no longer called in this path. `waitForGatewayHealthyRestart` may then report success by probing the unmanaged process's port, masking the respawn loop from the user.
The previous priority (SIGUSR1 to the unmanaged process first, launchd repair only when no listener exists) avoided this scenario by cleanly handing off before touching launchd. A guard that terminates any verified listener PIDs before bootstrapping would prevent the port-conflict respawn loop.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(daemon): reconcile macOS LaunchAgent..." | Re-trigger Greptile |
| if (process.platform === "darwin") { | ||
| const recovered = await recoverInstalledLaunchAgent({ result: "restarted" }); | ||
| if (recovered) { | ||
| return recovered; | ||
| } | ||
| } | ||
| const handled = await restartGatewayWithoutServiceManager(restartPort); | ||
| if (handled) { | ||
| restartedWithoutServiceManager = true; | ||
| return handled; | ||
| } | ||
| return await recoverInstalledLaunchAgent({ result: "restarted" }); | ||
| return null; |
There was a problem hiding this comment.
Launchd respawn loop when unmanaged process holds the port
When onNotLoaded fires and an unmanaged gateway process is already listening on the port (the split-brain state with pid 4200 in the updated test), calling recoverInstalledLaunchAgent first runs launchctl bootstrap + kickstart -k. kickstart returns 0 as soon as launchd spawns the new process, regardless of whether it stays alive. The new launchd-managed process then fails to bind the port (still held by the unmanaged process) and exits. launchd detects the exit and immediately respawns — creating a respawn loop.
The unmanaged process (pid 4200) is never signaled because signalVerifiedGatewayPidSync is no longer called in this path. waitForGatewayHealthyRestart may then report success by probing the unmanaged process's port, masking the respawn loop from the user.
The previous priority (SIGUSR1 to the unmanaged process first, launchd repair only when no listener exists) avoided this scenario by cleanly handing off before touching launchd. A guard that terminates any verified listener PIDs before bootstrapping would prevent the port-conflict respawn loop.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/daemon-cli/lifecycle.ts
Line: 204-215
Comment:
**Launchd respawn loop when unmanaged process holds the port**
When `onNotLoaded` fires and an unmanaged gateway process is already listening on the port (the split-brain state with pid 4200 in the updated test), calling `recoverInstalledLaunchAgent` first runs `launchctl bootstrap` + `kickstart -k`. `kickstart` returns 0 as soon as launchd spawns the new process, regardless of whether it stays alive. The new launchd-managed process then fails to bind the port (still held by the unmanaged process) and exits. launchd detects the exit and immediately respawns — creating a respawn loop.
The unmanaged process (pid 4200) is never signaled because `signalVerifiedGatewayPidSync` is no longer called in this path. `waitForGatewayHealthyRestart` may then report success by probing the unmanaged process's port, masking the respawn loop from the user.
The previous priority (SIGUSR1 to the unmanaged process first, launchd repair only when no listener exists) avoided this scenario by cleanly handing off before touching launchd. A guard that terminates any verified listener PIDs before bootstrapping would prevent the port-conflict respawn loop.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Context
This carries forward the open launchd lifecycle reports in #67335, #53475, and #71060, with related auto-update context from #58890/#60885 and restart sequencing notes from #70801.
Validation
Credit
Thanks to @ze1tgeist88, @dafacto, and @vishutdhar for the concrete macOS LaunchAgent repro data.
ProjectClownfish replacement details: