Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
55b65182d3 fix: harden darwin restart landing (#39763) (thanks @daymade) 2026-03-08 13:42:35 +00:00
daymade
2bdebdef02 Add regression test and CHANGELOG entry
- Add test ensuring launchd path never returns "failed" status
- Add CHANGELOG.md entry documenting the fix with issue/PR references
- Reference ThrottleInterval evolution (#27650#29078 → current 1s)
2026-03-08 13:40:01 +00:00
daymade
067a6ee106 chore: condense inline comments per code review
Remove redundant rationale from test body (test names already convey it)
and trim the production comment to what/consequence/link (mechanism
details live in #39760).
2026-03-08 13:40:01 +00:00
daymade
d6cc1c667f fix(darwin): remove self-kickstart from launchd gateway restart; rely on KeepAlive
When the gateway needs a config-triggered restart under launchd, calling
`launchctl kickstart -k` from within the service itself races with
launchd's async bootout state machine:

1. `kickstart -k` initiates a launchd bootout → SIGTERM to self
2. Gateway ignores SIGTERM during shutdown → process doesn't exit
3. 2s `spawnSync` timeout kills the launchctl child, but launchd
   continues the bootout asynchronously
4. Fallback `launchctl bootstrap` fails with EIO (service mid-bootout)
5. In-process restart runs on the same PID that launchd will SIGKILL
6. LaunchAgent is permanently unloaded — no auto-restart

Fix: on darwin/launchd, skip `triggerOpenClawRestart()` entirely.
The caller already calls `exitProcess(0)` for supervised mode, and
`KeepAlive=true` (always set in the plist template) restarts the
service within ~1 second.

The schtasks (Windows) path is unchanged — Windows doesn't have an
equivalent KeepAlive mechanism.
2026-03-08 13:40:01 +00:00
3 changed files with 41 additions and 17 deletions

View File

@ -725,6 +725,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760, #39763) Thanks @daymade.
- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting.
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@ -46,16 +46,15 @@ function clearSupervisorHints() {
}
}
function expectLaunchdKickstartSupervised(params?: { launchJobLabel?: string }) {
function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) {
setPlatform("darwin");
if (params?.launchJobLabel) {
process.env.LAUNCH_JOB_LABEL = params.launchJobLabel;
}
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" });
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("supervised");
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
}
@ -67,35 +66,34 @@ describe("restartGatewayProcessWithFreshPid", () => {
expect(spawnMock).not.toHaveBeenCalled();
});
it("returns supervised when launchd hints are present on macOS", () => {
it("returns supervised when launchd hints are present on macOS (no kickstart)", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" });
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("supervised");
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it("runs launchd kickstart helper on macOS when launchd label is set", () => {
expectLaunchdKickstartSupervised({ launchJobLabel: "ai.openclaw.gateway" });
it("returns supervised on macOS when launchd label is set (no kickstart)", () => {
expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" });
});
it("returns failed when launchd kickstart helper fails", () => {
it("launchd supervisor never returns failed regardless of triggerOpenClawRestart outcome", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
// Even if triggerOpenClawRestart *would* fail, launchd path must not call it.
triggerOpenClawRestartMock.mockReturnValue({
ok: false,
method: "launchctl",
detail: "spawn failed",
detail: "Bootstrap failed: 5: Input/output error",
});
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("failed");
expect(result.detail).toContain("spawn failed");
expect(result.mode).toBe("supervised");
expect(result.mode).not.toBe("failed");
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
});
it("does not schedule kickstart on non-darwin platforms", () => {
@ -133,7 +131,7 @@ describe("restartGatewayProcessWithFreshPid", () => {
it("returns supervised when OPENCLAW_LAUNCHD_LABEL is set (stock launchd plist)", () => {
clearSupervisorHints();
expectLaunchdKickstartSupervised();
expectLaunchdSupervisedWithoutKickstart();
});
it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => {
@ -157,6 +155,27 @@ describe("restartGatewayProcessWithFreshPid", () => {
expect(spawnMock).not.toHaveBeenCalled();
});
it("returns failed when Scheduled Task restart helper fails on Windows", () => {
clearSupervisorHints();
setPlatform("win32");
process.env.OPENCLAW_SERVICE_MARKER = "openclaw";
process.env.OPENCLAW_SERVICE_KIND = "gateway";
triggerOpenClawRestartMock.mockReturnValue({
ok: false,
method: "schtasks",
detail: "ERROR: Task not found.",
});
const result = restartGatewayProcessWithFreshPid();
expect(result).toEqual({
mode: "failed",
detail: "ERROR: Task not found.",
});
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
expect(spawnMock).not.toHaveBeenCalled();
});
it("keeps generic service markers out of non-Windows supervisor detection", () => {
clearSupervisorHints();
setPlatform("linux");

View File

@ -30,7 +30,11 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult {
}
const supervisor = detectRespawnSupervisor(process.env);
if (supervisor) {
if (supervisor === "launchd" || supervisor === "schtasks") {
// launchd: exit(0) is sufficient — KeepAlive=true restarts the service.
// Self-issued `kickstart -k` races with launchd's bootout state machine
// and can leave the LaunchAgent permanently unloaded.
// See: https://github.com/openclaw/openclaw/issues/39760
if (supervisor === "schtasks") {
const restart = triggerOpenClawRestart();
if (!restart.ok) {
return {