Skip to content

fix(daemon): reconcile macOS LaunchAgent supervision state#72616

Merged
vincentkoc merged 1 commit into
mainfrom
clownfish/ghcrawl-156577-autonomous-smoke
Apr 27, 2026
Merged

fix(daemon): reconcile macOS LaunchAgent supervision state#72616
vincentkoc merged 1 commit into
mainfrom
clownfish/ghcrawl-156577-autonomous-smoke

Conversation

@vincentkoc

Copy link
Copy Markdown
Member

Summary

  • detect macOS LaunchAgent split-brain states where the plist exists and the gateway listener/RPC is healthy but launchd no longer has the job loaded
  • make restart/repair restore launchd supervision instead of continuing as an unmanaged gateway process
  • add regression coverage for raw SIGTERM and long drain stop/restart paths that leave the LaunchAgent unloaded

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

  • pnpm check:changed
  • pnpm -s vitest run src/daemon/launchd.test.ts

Credit

Thanks to @ze1tgeist88, @dafacto, and @vishutdhar for the concrete macOS LaunchAgent repro data.

ProjectClownfish replacement details:

@vincentkoc
vincentkoc merged commit 60d4d5e into main Apr 27, 2026
10 of 11 checks passed
@aisle-research-bot

aisle-research-bot Bot commented Apr 27, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟠 High PATH hijack risk: launchctl executed without absolute path in execLaunchctl
1. 🟠 PATH hijack risk: launchctl executed without absolute path in execLaunchctl
Property Value
Severity High
CWE CWE-426
Location src/daemon/launchd.ts:105-112

Description

The macOS LaunchAgent management code executes launchctl by name (not an absolute path) via execFile, inheriting the caller environment by default.

  • execLaunchctl() sets file to "launchctl" on non-Windows platforms
  • execFileUtf8() calls Node's execFile() without overriding env, so it inherits process.env including attacker-influenced PATH
  • If a user runs the CLI in an environment where PATH is manipulated (or when running under sudo with an unsafe PATH), a malicious launchctl earlier in PATH could be executed, leading to arbitrary code execution with the privileges of the CLI process.

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.

Recommendation

Invoke launchctl using an absolute path and consider passing a minimal/safe environment.

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 /bin/launchctl on macOS so PATH is not consulted.


Analyzed PR: #72616 at commit 1c6c538

Last updated on: 2026-04-27T05:41:10Z

@vincentkoc
vincentkoc deleted the clownfish/ghcrawl-156577-autonomous-smoke branch April 27, 2026 05:39
@vincentkoc vincentkoc added the clawsweeper Tracked by ClawSweeper automation label Apr 27, 2026
@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime cli CLI command changes commands Command implementations size: S maintainer Maintainer-authored PR labels Apr 27, 2026
@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR detects and repairs the macOS LaunchAgent "split-brain" state — where the plist is installed and the gateway listener/RPC is healthy but launchd no longer has the job loaded — by surfacing a new missingSupervision runtime flag and invoking recoverInstalledLaunchAgent (bootstrap + kickstart) before falling back to unmanaged restart. It also removes a pre-existing bug in the doctor flow where the isLaunchAgentListed gate prevented repair from firing in the exact split-brain condition it was meant to catch.

  • The priority inversion in lifecycle.ts (recoverInstalledLaunchAgent now runs before restartGatewayWithoutServiceManager) can leave a launchd respawn loop when an unmanaged process is already holding the port at restart time. kickstart -k returns 0 immediately after launchd spawns the new process; the new process exits on port-bind failure; launchd respawns repeatedly. The unmanaged process is never terminated because signalVerifiedGatewayPidSync is no longer called in this path. The fix is to terminate any verified listener PIDs before calling recoverInstalledLaunchAgent.

Confidence Score: 3/5

The 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 lifecycle.ts) pulls the score to 3. The rest of the changes — the missingSupervision type/hint plumbing, the isLaunchAgentListed removal in the doctor flow, and the new regression tests — are clean and correct.

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 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.

Reviews (1): Last reviewed commit: "fix(daemon): reconcile macOS LaunchAgent..." | Re-trigger Greptile

Comment on lines +204 to +215
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

clawsweeper Tracked by ClawSweeper automation cli CLI command changes commands Command implementations gateway Gateway runtime maintainer Maintainer-authored PR size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant