Skip to content

fix(daemon): enable LaunchAgent before bootstrap on restart#39237

Merged
steipete merged 2 commits intoopenclaw:mainfrom
scoootscooob:fix/launchd-restart-enable
Mar 9, 2026
Merged

fix(daemon): enable LaunchAgent before bootstrap on restart#39237
steipete merged 2 commits intoopenclaw:mainfrom
scoootscooob:fix/launchd-restart-enable

Conversation

@scoootscooob
Copy link
Copy Markdown
Contributor

Summary

  • Add launchctl enable call before bootstrap in restartLaunchAgent(), matching the existing guard in installLaunchAgent()
  • launchd can persist a "disabled" state after bootout, causing bootstrap to fail and leaving the gateway unloaded until manual reinstall

Root cause

installLaunchAgent (line 414) already calls enable before bootstrap with the comment:

launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.

But restartLaunchAgent was missing this step, leaving a window where bootstrap silently fails after bootout.

Test plan

  • Updated existing test to verify bootout → enable → bootstrap → kickstart order
  • All 16 launchd tests pass

Fixes #39211

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime size: XS labels Mar 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR correctly fixes a latent restart failure in the macOS LaunchAgent lifecycle by adding launchctl enable before bootstrap in restartLaunchAgent(), matching the identical guard that already existed in installLaunchAgent(). The root cause (launchd persisting a "disabled" state across bootout) is real and the fix is the right solution.

What's fixed:

  • The enable call is inserted at exactly the right point — after waitForPidExit and before bootstrap — consistent with installLaunchAgent.
  • The test properly asserts the full bootout → enable → bootstrap → kickstart ordering.

Remaining gap:
The repairLaunchAgentBootstrap function (lines 202–218) calls bootstrap directly without an enable guard, making it vulnerable to the same "disabled state" failure that this PR addresses. Because this is the recovery/repair path, leaving it unguarded could be more harmful than the original restartLaunchAgent bug.

Confidence Score: 3/5

  • The fix is correct and safe to merge, but a sibling recovery function has the same gap, leaving a latent bug in the repair path.
  • The core PR change is a targeted, correct fix that mirrors an existing pattern. Tests are updated appropriately for the main restart path. However, the repairLaunchAgentBootstrap function has the identical missing enable guard. Since this is the recovery/repair path, hitting this bug there would be more harmful than in the main flow. Score is 3 rather than 4 because this unaddressed gap in a critical recovery function is a meaningful concern.
  • The repairLaunchAgentBootstrap function in src/daemon/launchd.ts (lines 202–218) should receive the same enable guard before its bootstrap call to prevent the same class of failure in the recovery path.

Comments Outside Diff (1)

  1. src/daemon/launchd.ts, line 209-213 (link)

    repairLaunchAgentBootstrap calls bootstrap directly without first calling enable, so it has the same latent failure mode described in the PR — if launchd has persisted a "disabled" state (e.g., after a previous bootout), the bootstrap here will fail and return { ok: false }, leaving the gateway unloaded. Since this function is the recovery/repair path, this gap is arguably more critical than in the main restartLaunchAgent flow.

    Consider applying the same guard that was just added to restartLaunchAgent:

Last reviewed commit: e6cbd10

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 8, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🔵 Low Unvalidated OPENCLAW_LAUNCHD_LABEL allows arbitrary launchctl service enable and plist path traversal during restart/repair

1. 🔵 Unvalidated OPENCLAW_LAUNCHD_LABEL allows arbitrary launchctl service enable and plist path traversal during restart/repair

Property Value
Severity Low
CWE CWE-22
Location src/daemon/launchd.ts:207-214

Description

resolveLaunchAgentLabel() accepts an override from env.OPENCLAW_LAUNCHD_LABEL with no validation. The new change unconditionally runs launchctl enable using that label before bootstrapping.

Security impact if the caller’s environment is attacker-influenced (e.g., higher-privileged automation invoking this code while inheriting untrusted env):

  • Arbitrary service enable: An attacker can set OPENCLAW_LAUNCHD_LABEL to an unintended launchd label, causing launchctl enable gui/<uid>/<label> to run. Because the result is not checked, the function may still proceed/fail later while the enable side-effect remains.
  • Arbitrary plist bootstrap path via path traversal/absolute path injection: The same label is used to construct the plist path as .../Library/LaunchAgents/${label}.plist via path.posix.join(...). If label contains /, .., or begins with /, this can resolve outside ~/Library/LaunchAgents/ and cause launchctl bootstrap to attempt to load an arbitrary plist path.

Vulnerable code (new behavior):

const label = resolveLaunchAgentLabel({ env });
const plistPath = resolveLaunchAgentPlistPath(env);
await execLaunchctl(["enable", `${domain}/${label}`]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);

Label source + plist path construction:

const envLabel = args?.env?.OPENCLAW_LAUNCHD_LABEL?.trim();
if (envLabel) return envLabel;
...
return path.posix.join(home, "Library", "LaunchAgents", `${label}.plist`);

Recommendation

Treat OPENCLAW_LAUNCHD_LABEL as untrusted input and constrain it to safe, expected values.

  1. Validate/allowlist labels before using them in any launchctl operation:
function assertSafeLaunchdLabel(label: string): string {// Reverse-DNS style, no slashes, no traversal, reasonable charset.
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(label)) {
    throw new Error(`Invalid launchd label: ${label}`);
  }
  if (label.includes("/") || label.includes("\\") || label.includes("..")) {
    throw new Error(`Invalid launchd label: ${label}`);
  }
  return label;
}

const label = assertSafeLaunchdLabel(resolveLaunchAgentLabel({ env }));

If overrides are not required in production, remove OPENCLAW_LAUNCHD_LABEL support or restrict it to a fixed allowlist (e.g., ai.openclaw.gateway and ai.openclaw.node).

  1. Harden plist path resolution: after building the path, resolve it and ensure it stays under the intended directory.
const launchAgentsDir = path.posix.join(home, "Library", "LaunchAgents");
const plistPath = path.posix.join(launchAgentsDir, `${label}.plist`);
const resolved = path.posix.normalize(plistPath);
if (!resolved.startsWith(`${launchAgentsDir}/`)) {
  throw new Error("Resolved plist path escapes LaunchAgents directory");
}
  1. Consider checking the exit code from launchctl enable and failing closed when it errors, to avoid partial side-effects that are hard to reason about.

Analyzed PR: #39237 at commit 5334675

Last updated on: 2026-03-09T02:26:04Z

@scoootscooob
Copy link
Copy Markdown
Contributor Author

Addressed the gap flagged by @greptile-apps — pushed a second commit that adds the same enable guard to repairLaunchAgentBootstrap (lines 209-213).

All three LaunchAgent lifecycle paths (install, restart, repair) now consistently call launchctl enable before bootstrap to clear any persisted "disabled" state.

Updated test verifies the enable → bootstrap → kickstart ordering in the repair path as well.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 8, 2026

Perfect! The fix looks good. All three LaunchAgent paths now have the same defensive enable call before bootstrap:

  1. installLaunchAgent (line 417) ✓
  2. restartLaunchAgent (line 471) ✓
  3. repairLaunchAgentBootstrap (line 209) ✓

The comments match, and the tests correctly verify the ordering. This completely closes the gap — no more silent bootstrap failures from persisted "disabled" state.

scoootscooob and others added 2 commits March 8, 2026 19:02
restartLaunchAgent was missing the launchctl enable call that
installLaunchAgent already performs. launchd can persist a "disabled"
state after bootout, causing bootstrap to silently fail and leaving the
gateway unloaded until a manual reinstall.

Fixes openclaw#39211

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The repair/recovery path had the same missing `enable` guard as
`restartLaunchAgent`.  If launchd persists a "disabled" state after a
previous `bootout`, the `bootstrap` call in `repairLaunchAgentBootstrap`
fails silently, leaving the gateway unloaded in the recovery flow.

Add the same `enable` guard before `bootstrap` that was already applied
to `installLaunchAgent` and (in this PR) `restartLaunchAgent`.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@scoootscooob scoootscooob force-pushed the fix/launchd-restart-enable branch from d2328ab to 5334675 Compare March 9, 2026 02:02
@steipete steipete merged commit 44beb7b into openclaw:main Mar 9, 2026
26 of 28 checks passed
@steipete
Copy link
Copy Markdown
Contributor

steipete commented Mar 9, 2026

Landed via temp rebase onto main.

  • Gate: pnpm check && pnpm build && pnpm test
  • Land commit: 43e649d
  • Merge commit: 44beb7b

Thanks @scoootscooob!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: macOS LaunchAgent restart can leave ai.openclaw.gateway unloaded after bootout

2 participants