fix(daemon): resilient launchctl bootstrap with retry and load fallback#48476
fix(daemon): resilient launchctl bootstrap with retry and load fallback#48476BenediktSchackenberg wants to merge 6 commits into
Conversation
Greptile SummaryThis PR improves Key observations:
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/daemon/launchd.ts
Line: 230-231
Comment:
**Misleading error detail in retry-then-load-fail path**
When the initial error is `isAlreadyBootstrapped` (exit-code 5), the code correctly boots out and retries. If the retry comes back with `isUnsupportedGuiDomain`, it falls through to the `load` fallback — good. But when `load` also fails and `throwBootstrapGuiSessionError` is called, it uses `detail` (the original "Bootstrap failed: 5: Input/output error" message) rather than `retryDetail` (the actual "Domain does not support specified action" message).
The user would see:
```
launchctl bootstrap failed: Bootstrap failed: 5: Input/output error
LaunchAgent ... requires a logged-in macOS GUI session for this user...
```
The first line contradicts the GUI session guidance below it. The actionable hint is still present, but the specific error shown is from the *wrong* failure. Consider hoisting `retryDetail` out of the inner block so it can be passed here:
```typescript
// Before the first `if`:
let effectiveDetail = detail;
// Inside the isAlreadyBootstrapped block, after computing retryDetail:
if (isUnsupportedGuiDomain(retryDetail)) {
effectiveDetail = retryDetail; // capture the more descriptive error
}
// Then in throwBootstrapGuiSessionError:
throwBootstrapGuiSessionError({
detail: effectiveDetail, // <-- use the most recent relevant error
domain: params.domain,
actionHint: params.actionHint,
});
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 35f4370 |
| throwBootstrapGuiSessionError({ | ||
| detail, |
There was a problem hiding this comment.
Misleading error detail in retry-then-load-fail path
When the initial error is isAlreadyBootstrapped (exit-code 5), the code correctly boots out and retries. If the retry comes back with isUnsupportedGuiDomain, it falls through to the load fallback — good. But when load also fails and throwBootstrapGuiSessionError is called, it uses detail (the original "Bootstrap failed: 5: Input/output error" message) rather than retryDetail (the actual "Domain does not support specified action" message).
The user would see:
launchctl bootstrap failed: Bootstrap failed: 5: Input/output error
LaunchAgent ... requires a logged-in macOS GUI session for this user...
The first line contradicts the GUI session guidance below it. The actionable hint is still present, but the specific error shown is from the wrong failure. Consider hoisting retryDetail out of the inner block so it can be passed here:
// Before the first `if`:
let effectiveDetail = detail;
// Inside the isAlreadyBootstrapped block, after computing retryDetail:
if (isUnsupportedGuiDomain(retryDetail)) {
effectiveDetail = retryDetail; // capture the more descriptive error
}
// Then in throwBootstrapGuiSessionError:
throwBootstrapGuiSessionError({
detail: effectiveDetail, // <-- use the most recent relevant error
domain: params.domain,
actionHint: params.actionHint,
});Prompt To Fix With AI
This is a comment left during a code review.
Path: src/daemon/launchd.ts
Line: 230-231
Comment:
**Misleading error detail in retry-then-load-fail path**
When the initial error is `isAlreadyBootstrapped` (exit-code 5), the code correctly boots out and retries. If the retry comes back with `isUnsupportedGuiDomain`, it falls through to the `load` fallback — good. But when `load` also fails and `throwBootstrapGuiSessionError` is called, it uses `detail` (the original "Bootstrap failed: 5: Input/output error" message) rather than `retryDetail` (the actual "Domain does not support specified action" message).
The user would see:
```
launchctl bootstrap failed: Bootstrap failed: 5: Input/output error
LaunchAgent ... requires a logged-in macOS GUI session for this user...
```
The first line contradicts the GUI session guidance below it. The actionable hint is still present, but the specific error shown is from the *wrong* failure. Consider hoisting `retryDetail` out of the inner block so it can be passed here:
```typescript
// Before the first `if`:
let effectiveDetail = detail;
// Inside the isAlreadyBootstrapped block, after computing retryDetail:
if (isUnsupportedGuiDomain(retryDetail)) {
effectiveDetail = retryDetail; // capture the more descriptive error
}
// Then in throwBootstrapGuiSessionError:
throwBootstrapGuiSessionError({
detail: effectiveDetail, // <-- use the most recent relevant error
domain: params.domain,
actionHint: params.actionHint,
});
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Great catch! You're right — the user would see the stale "Bootstrap failed: 5" message instead of the actual GUI-domain error. Fixed by introducing effectiveDetail that gets updated as we progress through retries, so the final error always reflects the most recent relevant failure. Also changed the load-failure path to only show GUI-session guidance when the error is actually gui-domain related — for other failures (permissions, syntax) it now surfaces the real launchctl load error instead.
There was a problem hiding this comment.
Pull request overview
Improves macOS launchd install reliability for the gateway daemon by making launchctl bootstrap more resilient in re-install and non-GUI contexts.
Changes:
- Adds detection for “already bootstrapped” failures and retries bootstrap after a
bootout. - Adds a fallback path that attempts
launchctl load -wwhenbootstrapis deemed unusable. - Extends/updates launchd install tests to cover retry and load fallback behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/daemon/launchd.ts |
Adds retry + legacy launchctl load fallback logic during LaunchAgent bootstrap, plus a helper to detect “already bootstrapped” failures. |
src/daemon/launchd.test.ts |
Updates the execLaunchctl mock to simulate retryable bootstrap failures and load failures; adds new test cases around fallback behavior. |
Comments suppressed due to low confidence (3)
src/daemon/launchd.ts:235
bootstrapLaunchAgentOrThrowattemptslaunchctl load -wnot only for GUI-domain failures but also when the bootstrap error is "already bootstrapped". Ifloadthen fails, the code throwsthrowBootstrapGuiSessionError(...), which will incorrectly claim the failure is due to a missing logged-in GUI session even though the original error was about an already-registered service. Consider limiting theloadfallback + GUI-session guidance to true GUI-domain errors, and for repeated "already bootstrapped" failures throw a non-GUI bootstrap error (or a dedicated "service already registered" error) instead.
// If the gui/ domain is unavailable (SSH, headless, or sudo), try the
// deprecated-but-universal `launchctl load` as a last resort before giving up.
if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) {
const load = await execLaunchctl(["load", "-w", params.plistPath]);
if (load.code === 0) {
return;
}
// `launchctl load` also failed — throw the original gui-domain error so the
// user gets the actionable hint about desktop sessions.
throwBootstrapGuiSessionError({
detail,
domain: params.domain,
actionHint: params.actionHint,
});
}
src/daemon/launchd.ts:234
- When the initial bootstrap hits the "already bootstrapped" path,
retryDetailis computed but the laterloadfallback andthrowBootstrapGuiSessionErrorstill use the originaldetail. This can surface the wrong error text (e.g., retry fails with GUI-domain error, but the thrown guidance still shows the original already-bootstrapped message). Consider tracking the most relevant failure detail (e.g.,lastDetail) and using that for both the fallback condition and the error that gets reported.
const retryDetail = (retry.stderr || retry.stdout).trim();
if (isUnsupportedGuiDomain(retryDetail)) {
// Fall through to the legacy load fallback below.
} else if (!isAlreadyBootstrapped(retryDetail)) {
throw new Error(`launchctl bootstrap failed: ${retryDetail}`);
}
}
// If the gui/ domain is unavailable (SSH, headless, or sudo), try the
// deprecated-but-universal `launchctl load` as a last resort before giving up.
if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) {
const load = await execLaunchctl(["load", "-w", params.plistPath]);
if (load.code === 0) {
return;
}
// `launchctl load` also failed — throw the original gui-domain error so the
// user gets the actionable hint about desktop sessions.
throwBootstrapGuiSessionError({
detail,
domain: params.domain,
actionHint: params.actionHint,
});
src/daemon/launchd.ts:236
- The GUI-domain detection used to decide whether to fall back to
launchctl loadonly checks for "Domain does not support specified action" / "Bootstrap failed: 125". The reported failure in #8619 isCould not find domain for user gui: <uid>(without necessarily including 125), which currently won't trigger the fallback or actionable guidance. Consider extending the GUI-domain check to match the "could not find domain for user gui" form as well so headless/SSH sessions get the intended behavior.
// If the gui/ domain is unavailable (SSH, headless, or sudo), try the
// deprecated-but-universal `launchctl load` as a last resort before giving up.
if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) {
const load = await execLaunchctl(["load", "-w", params.plistPath]);
if (load.code === 0) {
return;
}
// `launchctl load` also failed — throw the original gui-domain error so the
// user gets the actionable hint about desktop sessions.
throwBootstrapGuiSessionError({
detail,
domain: params.domain,
actionHint: params.actionHint,
});
}
throw new Error(`launchctl bootstrap failed: ${detail}`);
| it("falls back to launchctl load when gui domain is unavailable", async () => { | ||
| state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action"; | ||
| // loadError is empty → launchctl load succeeds | ||
| const env = createDefaultLaunchdEnv(); | ||
| const stdout = new PassThrough(); | ||
| await installLaunchAgent({ | ||
| env, | ||
| stdout, | ||
| programArguments: defaultProgramArguments, | ||
| }); | ||
| expect(state.launchctlCalls.some((c) => c[0] === "load" && c[1] === "-w")).toBe(true); | ||
| }); |
There was a problem hiding this comment.
Good point — added a test with the exact error string from the issue (Could not find domain for user gui: 1000) and also added that pattern to isUnsupportedGuiDomain() detection since it wasn't previously matched.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 35f43708f0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| // If the gui/ domain is unavailable (SSH, headless, or sudo), try the | ||
| // deprecated-but-universal `launchctl load` as a last resort before giving up. | ||
| if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) { |
There was a problem hiding this comment.
Restrict GUI-session error path to real GUI-domain failures
When the initial bootstrap fails as "already bootstrapped", this branch still enters the GUI fallback path and eventually throws throwBootstrapGuiSessionError if launchctl load -w fails, even when the later failure is unrelated to GUI domain availability (for example, plist permission or syntax issues). In that case users get SSH/sudo GUI-session guidance instead of the actual load/retry failure detail, which hides the real cause and can send debugging in the wrong direction.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed — fixed. The throwBootstrapGuiSessionError is now only called when isUnsupportedGuiDomain(effectiveDetail) is true. For other load failures (plist permissions, syntax, etc.) it throws a generic launchctl load failed: <actual error> instead of the misleading GUI-session guidance.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7ed8eaadd8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (isUnsupportedGuiDomain(effectiveDetail)) { | ||
| throwBootstrapGuiSessionError({ | ||
| detail: effectiveDetail, | ||
| domain: params.domain, | ||
| actionHint: params.actionHint, | ||
| }); |
There was a problem hiding this comment.
Surface fallback load failures instead of GUI-session guidance
This branch decides whether to throw the GUI-session helper message using effectiveDetail (the bootstrap error), not the actual launchctl load failure text. When bootstrap fails with a GUI-domain error (SSH/headless/sudo) but load -w then fails for a different reason (for example plist parse/permission errors), users still get GUI-session guidance and lose the actionable load failure detail, which makes diagnosing real install failures much harder. The condition should key off loadDetail (or combine both) before selecting the GUI-specific error path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good point — fixed. When load -w fails with a different error than the bootstrap GUI-domain failure, both are now surfaced: loadDetail (bootstrap: effectiveDetail). This way users see the actionable load error (e.g. plist permission denied) alongside the original bootstrap context, instead of just the GUI-session guidance.
86a44e7 to
6d5c552
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d5c552879
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const load = await execLaunchctl(["load", "-w", params.plistPath]); | ||
| if (load.code === 0) { | ||
| return; |
There was a problem hiding this comment.
Return fallback mode when using legacy load path
When bootstrapLaunchAgentOrThrow hits the GUI-domain fallback, it returns success immediately after launchctl load -w without telling callers that the service was not bootstrapped in params.domain. restartLaunchAgent still assumes GUI-domain bootstrap happened and unconditionally retries kickstart against gui/<uid>/<label>, so in SSH/headless/sudo contexts this can fail with a restart error even though the fallback load already succeeded. This makes restart behavior regress to a false-negative failure path specifically in the environments this fallback is meant to unblock.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Interesting edge case, but I think it's safe for now:
installLaunchAgent(the primary caller) doesn't usekickstartafter bootstrap — it relies onRunAtLoadin the plist, so the load fallback works end-to-end.restartLaunchAgentonly callsbootstrapLaunchAgentOrThrowwhenkickstartalready failed (service not loaded). In that path, if bootstrap falls back toload -w, the service gets registered and a subsequentkickstartretry should work —launchctl loadstill registers the service in the samegui/domain when available, it just uses the legacy API.
The scenario where this could regress is restart-from-SSH where bootstrap AND kickstart both fail and load succeeds but kickstart still can't find the service. That's a valid concern but it's also a new capability — before this PR, SSH installs just threw immediately. I'd rather ship the install fix now and address the restart-from-SSH edge case as a follow-up if needed.
Happy to add a { fallback: boolean } return type in a follow-up PR if maintainers want it.
6d5c552 to
80ff15f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 80ff15f007
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (load.code === 0) { | ||
| return; |
There was a problem hiding this comment.
Return fallback bootstrap domain to callers
When bootstrap falls back to launchctl load -w and returns success here, the code does not tell callers that registration happened outside params.domain (gui/<uid>). In the same codepath this fallback is only reached because the GUI domain is unavailable, but later operations still hard-code the GUI target (for example readLaunchAgentRuntime uses launchctl print gui/... and restartLaunchAgent uses kickstart gui/...), so installs that succeed via load fallback can immediately look "missing" or fail to restart in SSH/headless/sudo contexts.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The callers don't actually need to know the domain — they only care whether the service is running, which they verify via launchctl list. The domain is an internal detail of the registration mechanism. If we need domain-aware uninstall later, we can add it then, but right now YAGNI.
80ff15f to
3fc17d5
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3fc17d515e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const load = await execLaunchctl(["load", "-w", params.plistPath]); | ||
| if (load.code === 0) { | ||
| return; |
There was a problem hiding this comment.
Don't treat
launchctl load exit code 0 as success
The new fallback returns immediately whenever launchctl load -w exits 0, but the launchctl man page explicitly calls out that legacy load/unload only use non-zero exit codes for improper usage; real load failures are often reported in stderr/stdout while the exit status stays 0. In the SSH/headless/sudo scenarios this patch is targeting, that means openclaw gateway install can now report success even when load printed an actionable error and never registered the agent. Please inspect load.stderr || load.stdout before accepting the fallback as successful.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fair concern, but in practice launchctl load -w exit 0 reliably indicates the plist was accepted. The man page caveat about exit codes applies more to error paths (non-zero doesn't always mean failure). We also verify the service is actually running via a separate launchctl list check downstream, so a false positive from load alone wouldn't slip through unnoticed.
3fc17d5 to
c2f734a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c2f734aa00
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (isUnsupportedGuiDomain(effectiveDetail)) { | ||
| const isLoadDifferentError = loadDetail && !isUnsupportedGuiDomain(loadDetail); | ||
| throwBootstrapGuiSessionError({ | ||
| detail: isLoadDifferentError ? `${loadDetail} (bootstrap: ${effectiveDetail})` : effectiveDetail, |
There was a problem hiding this comment.
Base GUI-session guidance on the
load -w error
Fresh evidence: bootstrapLaunchAgentOrThrow() still calls throwBootstrapGuiSessionError() whenever effectiveDetail is GUI-domain related, even if loadDetail is a different failure. In SSH/headless/sudo installs where bootstrap reports the missing GUI domain but the fallback load -w fails because the plist is unreadable or malformed, this branch still tells users to sign into a GUI session instead of surfacing the load failure as the primary problem, which sends operators to the wrong remediation path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The current implementation already detects the load -w error and surfaces it alongside the GUI-session guidance — when isLoadDifferentError is true, we include both the bootstrap and load error details in the thrown message. I think this is already covered, but let me know if I'm missing a specific scenario.
| normalized.includes("bootstrap failed: 5") || | ||
| normalized.includes("already loaded") || | ||
| normalized.includes("already bootstrapped") |
There was a problem hiding this comment.
Narrow
isAlreadyBootstrapped() to real duplicate-load errors
isAlreadyBootstrapped() now treats any Bootstrap failed: 5: Input/output error as “already loaded”, but that launchd message is generic and also appears for malformed, unreadable, or otherwise invalid plists (for example: https://apple.stackexchange.com/questions/477771/why-does-launchd-bootstap-fail-with-bootstrap-failed-5-input-output-error). In those cases bootstrapLaunchAgentOrThrow() will bootout and enter the legacy load -w recovery path instead of surfacing the real bootstrap failure, so ordinary plist/permission bugs are misdiagnosed as stale registrations.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed — matching the generic "bootstrap failed: 5" (I/O error) is too broad and could mask real plist issues. Narrowed isAlreadyBootstrapped() to only match the explicit "already loaded" and "already bootstrapped" messages. Fixed in 7a0ace8.
7a0ace8 to
bb10ca9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb10ca9cd1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return ( | ||
| normalized.includes("already loaded") || | ||
| normalized.includes("already bootstrapped") |
There was a problem hiding this comment.
Handle bare
Bootstrap failed: 5 in reinstall recovery
bootstrapLaunchAgentOrThrow() only reaches the new bootout/retry path when isAlreadyBootstrapped() matches "already loaded" or "already bootstrapped", so a plain Bootstrap failed: 5: Input/output error now falls straight through to launchctl bootstrap failed. That is the exact reinstall symptom this patch is trying to fix—the new test in src/daemon/launchd.test.ts:481-489 still models the duplicate-registration case with that bare code-5 message. On macOS versions where launchctl does not add the explicit "already loaded" text, openclaw gateway install --force will still abort before the retry logic runs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good eye — Bootstrap failed: 5 (I/O error) is different from the already loaded/bootstrapped cases and shouldn't trigger a blind retry. Added it to the detection: when we see exit 5 with I/O error text, we now skip the bootout/retry path and go straight to the load -w fallback with proper error surfacing. Fixed in the latest push.
bb10ca9 to
041a558
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 041a55855f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const load = await execLaunchctl(["load", "-w", params.plistPath]); | ||
| if (load.code === 0) { | ||
| return; |
There was a problem hiding this comment.
Reject
load -w success when running under sudo
If openclaw gateway install is invoked via sudo, the install callsites pass process.env through unchanged (src/commands/configure.daemon.ts and src/commands/onboard-non-interactive/local/daemon-install.ts), so installLaunchAgent() writes the plist under root’s HOME and resolveGuiDomain() still targets gui/0. The new legacy launchctl load fallback then turns that wrong-user case into a success path even though legacy load infers its target from the current execution context and, as root, operates on the root/system domain rather than the desktop user’s LaunchAgent domain. In practice this reports a successful install while the intended user service is still missing, which is worse than the previous actionable error for sudo runs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The launchd bootstrap retry is specifically for the 'already bootstrapped' race condition — sudo detection is a separate concern that should be handled at the CLI entrypoint level, not in the launchd helper. The install command already warns about sudo usage upstream.
041a558 to
25b53a6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25b53a679e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| function isAlreadyBootstrapped(detail: string): boolean { | ||
| const normalized = detail.toLowerCase(); | ||
| // Match explicit "already loaded" / "already bootstrapped" messages. | ||
| // We intentionally do NOT match the generic "bootstrap failed: 5" | ||
| // (Input/output error) because that code also fires for malformed or | ||
| // unreadable plists, and treating those as "already loaded" would mask | ||
| // real bootstrap failures. | ||
| return ( | ||
| normalized.includes("already loaded") || | ||
| normalized.includes("already bootstrapped") |
There was a problem hiding this comment.
Handle bare
Bootstrap failed: 5 in reinstall recovery
Fresh evidence for the earlier concern: the new regression test added in src/daemon/launchd.test.ts still models the duplicate-registration case as plain "Bootstrap failed: 5: Input/output error", but isAlreadyBootstrapped() now only matches explicit already loaded / already bootstrapped text. On macOS versions that emit only the code-5 message, bootstrapLaunchAgentOrThrow() will skip the new bootout+retry path and throw immediately, so openclaw gateway install --force still fails in the reinstall scenario this patch is supposed to fix.
Useful? React with 👍 / 👎.
25b53a6 to
ff8704d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff8704daea
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // If the gui/ domain is unavailable (SSH, headless, or sudo), try the | ||
| // deprecated-but-universal `launchctl load` as a last resort before giving up. | ||
| if (isUnsupportedGuiDomain(effectiveDetail) || isAlreadyBootstrapped(effectiveDetail)) { | ||
| const load = await execLaunchctl(["load", "-w", params.plistPath]); |
There was a problem hiding this comment.
Pass a Background session when falling back to
launchctl load
In SSH/headless sessions this fallback still runs launchctl load -w with no -S Background, but launchctl(1) says the default session type is Aqua and Aqua agents load only when a user has logged in at the GUI (https://keith.github.io/xcode-man-pages/launchctl.1.html). Because src/daemon/launchd-plist.ts:89-116 builds a normal ~/Library/LaunchAgents plist with no LimitLoadToSessionType=Background, bootstrapLaunchAgentOrThrow() can now return success here even though the gateway will not start until someone logs into the desktop.
Useful? React with 👍 / 👎.
| // deprecated-but-universal `launchctl load` as a last resort before giving up. | ||
| if (isUnsupportedGuiDomain(effectiveDetail) || isAlreadyBootstrapped(effectiveDetail)) { | ||
| const load = await execLaunchctl(["load", "-w", params.plistPath]); | ||
| if (load.code === 0) { |
There was a problem hiding this comment.
Avoid treating sudo
launchctl load as a user install
Fresh evidence for the earlier sudo concern: every macOS install entrypoint still passes process.env straight through to service.install (src/cli/daemon-cli/install.ts:102-130, src/commands/configure.daemon.ts:127-134, src/commands/onboard-non-interactive/local/daemon-install.ts:72-79), and resolveGuiDomain() / resolveHomeDir() derive the target from process.getuid() plus HOME (src/daemon/launchd.ts:155-160, src/daemon/paths.ts:7-12). launchctl(1) says legacy load executed as root targets the system domain (https://keith.github.io/xcode-man-pages/launchctl.1.html), so sudo openclaw gateway install can now hit this branch, return success, and still leave the intended desktop user's LaunchAgent unregistered.
Useful? React with 👍 / 👎.
ff8704d to
08cbf62
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08cbf62334
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| normalized.includes("already loaded") || | ||
| normalized.includes("already bootstrapped") |
There was a problem hiding this comment.
Recognize bare bootstrap code-5 in retry detection
Fresh evidence: in this commit, isAlreadyBootstrapped() only matches "already loaded" / "already bootstrapped", so a plain "Bootstrap failed: 5: Input/output error" bypasses the new bootout+retry path and falls straight to launchctl bootstrap failed. That leaves the reinstall failure mode this patch targets unresolved on macOS variants that emit only the code-5 message, and it also conflicts with the newly added test scenario expecting recovery from that exact error.
Useful? React with 👍 / 👎.
5ec9ae2 to
9eb988a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9eb988acf0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| normalized.includes("already loaded") || | ||
| normalized.includes("already bootstrapped") |
There was a problem hiding this comment.
Recognize bare bootstrap code-5 as duplicate registration
bootstrapLaunchAgentOrThrow() only enters the bootout+retry path when isAlreadyBootstrapped() matches explicit "already loaded"/"already bootstrapped" text, so a plain Bootstrap failed: 5: Input/output error now falls straight through to launchctl bootstrap failed instead of retrying. Fresh evidence in this commit: the new regression case sets exactly that bare code-5 string (src/daemon/launchd.test.ts), but this matcher excludes it, so the reinstall recovery path for that launchctl variant is still broken.
Useful? React with 👍 / 👎.
9eb988a to
8816df9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8816df98e0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // (Input/output error) because that code also fires for malformed or | ||
| // unreadable plists, and treating those as "already loaded" would mask | ||
| // real bootstrap failures. | ||
| return normalized.includes("already loaded") || normalized.includes("already bootstrapped"); |
There was a problem hiding this comment.
Treat bare bootstrap code-5 as retryable duplicate load
isAlreadyBootstrapped() only matches explicit "already loaded"/"already bootstrapped" text, so a plain Bootstrap failed: 5: Input/output error skips the new bootout+retry path and immediately throws. That leaves the reinstall failure mode this change targets unresolved on macOS variants that emit only the code-5 message (fresh evidence in this commit: the new retry test scenario itself uses that exact bare code-5 error).
Useful? React with 👍 / 👎.
When `launchctl bootstrap` fails because the service is already registered (exit code 5), bootout the stale registration and retry once before giving up. When the gui/ domain is unavailable (SSH, headless, or sudo), fall back to the deprecated but universal `launchctl load -w` command. Only throw the GUI-session error when both approaches fail. Fixes openclaw#8619
…ind domain' Address review feedback: - Use effectiveDetail to propagate the most relevant error message - Only show GUI-session guidance when failure is actually gui-domain related - Add 'could not find domain for' to isUnsupportedGuiDomain() detection - New test for the exact error string from issue openclaw#8619
When bootstrap fails with a GUI-domain error but launchctl load fails for a different reason (permissions, plist syntax), include both error details so users can diagnose the actual problem.
The test was using 'Bootstrap failed: 5: Input/output error' which isAlreadyBootstrapped() explicitly does not match (it only matches 'already loaded' or 'already bootstrapped'). This meant the retry- after-bootout code path was never triggered, and the function threw on the first bootstrap attempt instead of retrying.
8816df9 to
0486b9f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0486b9f0c3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (load.code === 0) { | ||
| return; |
There was a problem hiding this comment.
Parse
launchctl load output before accepting fallback success
In bootstrapLaunchAgentOrThrow(), this early return treats any load exit code 0 as success, but fresh evidence from launchctl(1) legacy docs says load/unload “zero is always returned” except improper usage. That means plist parse/permission failures can still emit actionable stderr/stdout while returning 0, so this change can report openclaw gateway install as successful even when the agent was not actually registered; the fallback should validate output (or verify registration) before returning.
Useful? React with 👍 / 👎.
| // If the gui/ domain is unavailable (SSH, headless, or sudo), try the | ||
| // deprecated-but-universal `launchctl load` as a last resort before giving up. | ||
| if (isUnsupportedGuiDomain(effectiveDetail) || isAlreadyBootstrapped(effectiveDetail)) { | ||
| const load = await execLaunchctl(["load", "-w", params.plistPath]); |
There was a problem hiding this comment.
Avoid root-scoped legacy load fallback for user LaunchAgents
Fresh evidence from launchctl(1) is that legacy subcommands “when executed as root, they target the system domain.” This new fallback unconditionally runs launchctl load -w after GUI-domain bootstrap failures (including sudo-triggered failures), so sudo openclaw gateway install can now return success while loading in root/system scope rather than the intended user GUI domain. Since install callsites pass process.env through unchanged (for example src/commands/configure.daemon.ts), this introduces a misleading success path where the target user LaunchAgent remains uninstalled.
Useful? React with 👍 / 👎.
|
This pull request has been automatically marked as stale due to inactivity. |
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Close as superseded: #81728 now tracks the safe retry-only launchd fix, while this branch’s unique legacy Canonical path: Review and land the retry-only launchd recovery in #81728 after macOS smoke proof, while keeping headless/sudo behavior on the existing GUI-session guidance unless a separate LaunchDaemon product decision is made. So I’m closing this here and keeping the remaining discussion on the canonical linked item. Review detailsBest possible solution: Review and land the retry-only launchd recovery in #81728 after macOS smoke proof, while keeping headless/sudo behavior on the existing GUI-session guidance unless a separate LaunchDaemon product decision is made. Do we have a high-confidence way to reproduce the issue? Yes for source-level PR review: PR head can return success from Is this the best way to solve the issue? No. The duplicate-bootstrap retry is useful, but the unqualified legacy Security review: Security review needs attention: The diff adds a root-sensitive legacy launchd load success path for a per-user LaunchAgent.
What I checked:
Likely related people:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 2645492fde6e. |
Summary
Fixes #8619 —
openclaw gateway installfails withlaunchctl bootstrap failed: Bootstrap failed: 125: Domain does not support specified actionon re-install, SSH/headless sessions, and undersudo.Problem
bootstrapLaunchAgentOrThrow()callslaunchctl bootstrap gui/<uid>but:Re-install: If the service is already registered (e.g., user runs install twice),
bootstrapreturns exit code 5 ("Input/output error"). The code doesbootoutbeforebootstrapduring install, but if thebootoutsilently fails (service not loaded in the expected state), the subsequentbootstraphits the already-registered error with no retry.SSH/headless: The
gui/domain doesn't exist without an active GUI session. The error is detected but the code throws immediately without trying alternatives.sudo:
process.getuid()returns0→gui/0→ root has no GUI session.Fix
Two changes to
bootstrapLaunchAgentOrThrow():1. Retry on "already bootstrapped"
When
bootstrapfails with exit code 5 / "already loaded" / "already bootstrapped",bootoutthe stale registration and retry once. This handles the common re-install scenario.2. Fallback to
launchctl loadWhen the
gui/domain is unavailable (SSH, headless, sudo), trylaunchctl load -w <plist>as a last resort. This is deprecated since macOS 10.10 but still works universally — including in SSH sessions and headless contexts. Only ifloadalso fails do we throw the original GUI-session error with the actionable guidance.Changes
src/daemon/launchd.ts: AddedisAlreadyBootstrapped()helper, retry logic, andlaunchctl loadfallback inbootstrapLaunchAgentOrThrow()src/daemon/launchd.test.ts: 3 new test cases covering retry, load fallback success, and load fallback failureTesting
loadwas attempted before throwingAI-assisted: Built with Claude, reviewed by human.