diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c23503488..3d69f3b9453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai - Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf. - WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal. - Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong. +- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq. - Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output. - Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id. - Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap. diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index eac1b227c0a..7f111146e6c 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -86,11 +86,35 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when launchd hints are present on macOS (no kickstart)", () => { + it("returns supervised when OpenClaw launchd markers are present on macOS (no kickstart)", () => { clearSupervisorHints(); expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" }); }); + it("returns supervised for a real gateway launchd job without the injected marker", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; + + const result = restartGatewayProcessWithFreshPid(); + + expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("returns supervised for a real gateway XPC launchd job without the injected marker", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway"; + + const result = restartGatewayProcessWithFreshPid(); + + expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + it("returns supervised on macOS when launchd label is set (no kickstart)", () => { expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" }); }); @@ -123,12 +147,18 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when XPC_SERVICE_NAME is set by launchd", () => { + it("does not treat inherited XPC_SERVICE_NAME as launchd supervision", () => { clearSupervisorHints(); setPlatform("darwin"); - process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway"; + process.env.XPC_SERVICE_NAME = "ai.openclaw.mac"; + process.env.OPENCLAW_PROFILE = "mac"; + const result = restartGatewayProcessWithFreshPid(); - expect(result.mode).toBe("supervised"); + + expect(result).toEqual({ + mode: "disabled", + detail: "unmanaged: use in-process restart to keep custom supervisor PID tracking stable", + }); expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); @@ -288,6 +318,29 @@ describe("respawnGatewayProcessForUpdate", () => { ); }); + it("spawns a detached update process when macOS only has inherited XPC state", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.XPC_SERVICE_NAME = "ai.openclaw.mac"; + process.execArgv = []; + process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"]; + spawnMock.mockReturnValue({ pid: 6161, unref: vi.fn(), kill: vi.fn() }); + + const result = respawnGatewayProcessForUpdate(); + + expect(result.mode).toBe("spawned"); + expect(result.pid).toBe(6161); + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + ["/repo/dist/index.js", "gateway", "run"], + { + detached: true, + env: process.env, + stdio: "inherit", + }, + ); + }); + it("returns failed when update detached respawn throws", () => { delete process.env.OPENCLAW_NO_RESPAWN; clearSupervisorHints(); diff --git a/src/infra/supervisor-markers.test.ts b/src/infra/supervisor-markers.test.ts index c14f9ba3e3d..4cb0c01fbe4 100644 --- a/src/infra/supervisor-markers.test.ts +++ b/src/infra/supervisor-markers.test.ts @@ -13,12 +13,34 @@ describe("SUPERVISOR_HINT_ENV_VARS", () => { }); describe("detectRespawnSupervisor", () => { - it("detects launchd and systemd only from non-blank platform-specific hints", () => { - expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: " ai.openclaw.gateway " }, "darwin")).toBe( + it("detects launchd from OpenClaw's explicit marker or current gateway launchd job", () => { + expect( + detectRespawnSupervisor({ OPENCLAW_LAUNCHD_LABEL: " ai.openclaw.gateway " }, "darwin"), + ).toBe("launchd"); + expect(detectRespawnSupervisor({ OPENCLAW_LAUNCHD_LABEL: " " }, "darwin")).toBeNull(); + expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway" }, "darwin")).toBe( "launchd", ); - expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: " " }, "darwin")).toBeNull(); + expect( + detectRespawnSupervisor( + { LAUNCH_JOB_NAME: "ai.openclaw.work", OPENCLAW_PROFILE: "work" }, + "darwin", + ), + ).toBe("launchd"); + expect(detectRespawnSupervisor({ LAUNCH_JOB_LABEL: "ai.openclaw.mac" }, "darwin")).toBeNull(); + expect(detectRespawnSupervisor({ XPC_SERVICE_NAME: "ai.openclaw.mac" }, "darwin")).toBeNull(); + expect( + detectRespawnSupervisor( + { XPC_SERVICE_NAME: "ai.openclaw.mac", OPENCLAW_PROFILE: "mac" }, + "darwin", + ), + ).toBeNull(); + expect(detectRespawnSupervisor({ XPC_SERVICE_NAME: "ai.openclaw.gateway" }, "darwin")).toBe( + "launchd", + ); + }); + it("detects systemd only from non-blank platform-specific hints", () => { expect(detectRespawnSupervisor({ INVOCATION_ID: "abc123" }, "linux")).toBe("systemd"); expect(detectRespawnSupervisor({ JOURNAL_STREAM: "" }, "linux")).toBeNull(); }); diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index cbe8d4807bf..c83ab42ea4b 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -1,10 +1,15 @@ +import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; + const SUPERVISOR_HINTS = { - launchd: ["LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", "XPC_SERVICE_NAME", "OPENCLAW_LAUNCHD_LABEL"], + launchd: ["OPENCLAW_LAUNCHD_LABEL"], systemd: ["OPENCLAW_SYSTEMD_UNIT", "INVOCATION_ID", "SYSTEMD_EXEC_PID", "JOURNAL_STREAM"], schtasks: ["OPENCLAW_WINDOWS_TASK_NAME"], } as const; export const SUPERVISOR_HINT_ENV_VARS = [ + "LAUNCH_JOB_LABEL", + "LAUNCH_JOB_NAME", + "XPC_SERVICE_NAME", ...SUPERVISOR_HINTS.launchd, ...SUPERVISOR_HINTS.systemd, ...SUPERVISOR_HINTS.schtasks, @@ -21,12 +26,24 @@ function hasAnyHint(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean { }); } +function isCurrentGatewayLaunchdJob(env: NodeJS.ProcessEnv): boolean { + const expectedLabel = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); + if ( + [env.LAUNCH_JOB_LABEL, env.LAUNCH_JOB_NAME].some((value) => value?.trim() === expectedLabel) + ) { + return true; + } + return env.XPC_SERVICE_NAME?.trim() === GATEWAY_LAUNCH_AGENT_LABEL; +} + export function detectRespawnSupervisor( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): RespawnSupervisor | null { if (platform === "darwin") { - return hasAnyHint(env, SUPERVISOR_HINTS.launchd) ? "launchd" : null; + return hasAnyHint(env, SUPERVISOR_HINTS.launchd) || isCurrentGatewayLaunchdJob(env) + ? "launchd" + : null; } if (platform === "linux") { return hasAnyHint(env, SUPERVISOR_HINTS.systemd) ? "systemd" : null;