diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fbcc5ad4a..43c65924a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +### Fixes + +- CLI/update: treat inherited Gateway service markers as origin hints and only block package replacement when the managed Gateway is still live, so self-updates can stop the service and continue safely. (#75729) Thanks @hxy91819. + ## 2026.5.2 ### Changes @@ -267,6 +275,7 @@ Docs: https://docs.openclaw.ai - Agents/status: resolve `session_status(sessionKey="current")` for sparse channel-plugin sessions after literal current lookups miss, so Scope, Slack, Discord, and other plugin-driven agents avoid retrying through `Unknown sessionKey: current`. Fixes #74141. (#72306) Thanks @bittoby. - Cron: retry recurring wake-now main-session jobs through temporary heartbeat busy skips before recording success, so queued cron events no longer appear as ok ghost runs while the main lane is still busy. Fixes #75964. (#76083) Thanks @kshetrajna12 and @xuruiray. + ## 2026.4.30 ### Changes diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 79ea19c974b..2933751dc6c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -184,7 +184,10 @@ vi.mock("../daemon/service.js", () => ({ ? (command.environment as NodeJS.ProcessEnv | undefined) : undefined), }; - const [loaded, runtime] = await Promise.all([serviceLoaded({ env }), serviceReadRuntime(env)]); + const [loaded, runtime] = await Promise.all([ + serviceLoaded({ env }).catch(() => false), + serviceReadRuntime(env).catch(() => undefined), + ]); return { installed: command !== null, loaded, @@ -612,6 +615,24 @@ describe("update-cli", () => { expect(runDaemonRestart).not.toHaveBeenCalled(); }); + it("does not carry gateway service markers into the post-core update process", async () => { + setupUpdatedRootRefresh(); + + await withEnvAsync( + { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + async () => { + await updateCommand({ yes: true }); + }, + ); + + const spawnEnv = (spawn.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + expect(spawnEnv?.OPENCLAW_SERVICE_MARKER).toBeUndefined(); + expect(spawnEnv?.OPENCLAW_SERVICE_KIND).toBeUndefined(); + }); + it("respawns into the updated git root before requested channel persistence", async () => { const { entrypoints } = setupUpdatedRootRefresh({ gatewayUpdateImpl: async (root) => @@ -1263,8 +1284,122 @@ describe("update-cli", () => { ).toContain("Low disk space near"); }); - it("refuses package updates from inside the gateway service process", async () => { + it("allows package updates from inherited gateway service env when the managed gateway is not running", async () => { mockPackageInstallStatus(createCaseDir("openclaw-update")); + serviceReadRuntime.mockResolvedValueOnce({ + status: "stopped", + state: "stopped", + }); + + await withEnvAsync( + { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + async () => { + await updateCommand({ yes: true }); + }, + ); + + expect(defaultRuntime.error).not.toHaveBeenCalledWith( + expect.stringContaining( + "Package updates cannot run from inside the gateway service process.", + ), + ); + expectPackageInstallSpec("openclaw@latest"); + }); + + it("refuses package updates from inherited gateway service env when --no-restart leaves the gateway running", async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + serviceReadCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + }); + serviceLoaded.mockResolvedValue(true); + + await withEnvAsync( + { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + async () => { + await updateCommand({ yes: true, restart: false }); + }, + ); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining( + "Package updates cannot run from inside the gateway service process.", + ), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(serviceStop).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).not.toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + }); + + it.each([ + { + name: "runtime probe fails", + setupRuntime: () => + serviceReadRuntime.mockRejectedValueOnce(new Error("runtime probe failed")), + }, + { + name: "runtime status is unknown", + setupRuntime: () => serviceReadRuntime.mockResolvedValueOnce({ status: "unknown" }), + }, + ])( + "refuses package updates from inherited gateway service env when $name", + async ({ setupRuntime }) => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + serviceReadCommand.mockResolvedValue({ + programArguments: ["openclaw", "gateway", "run"], + environment: { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + }); + setupRuntime(); + + await withEnvAsync( + { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + async () => { + await updateCommand({ yes: true }); + }, + ); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining( + "Package updates cannot run from inside the gateway service process.", + ), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(serviceStop).not.toHaveBeenCalled(); + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).not.toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + }, + ); + + it("refuses package updates from inherited gateway service env when the service definition is missing but runtime is live", async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + serviceReadCommand.mockResolvedValue(null); + serviceReadRuntime.mockResolvedValueOnce({ + status: "running", + pid: 4242, + state: "running", + }); await withEnvAsync( { @@ -1282,6 +1417,7 @@ describe("update-cli", () => { ), ); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(serviceStop).not.toHaveBeenCalled(); expect(runGatewayUpdate).not.toHaveBeenCalled(); expect(runCommandWithTimeout).not.toHaveBeenCalledWith( ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], @@ -1605,7 +1741,15 @@ describe("update-cli", () => { }; }); - await updateCommand({ yes: true }); + await withEnvAsync( + { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + async () => { + await updateCommand({ yes: true }); + }, + ); const npmInstallCallIndex = vi .mocked(runCommandWithTimeout) diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index ea74ef9d405..d4730da3ac4 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -162,6 +162,9 @@ export function shouldUseLegacyProcessRestartAfterUpdate(params: { type PrePackageServiceStop = { stopped: boolean; + inspected: boolean; + runtimeInspected: boolean; + running: boolean; serviceEnv?: NodeJS.ProcessEnv; }; @@ -175,11 +178,19 @@ async function maybeStopManagedServiceBeforePackageUpdate(params: { service = resolveGatewayService(); serviceState = await readGatewayServiceState(service, { env: process.env }); } catch { - return { stopped: false }; + return { stopped: false, inspected: false, runtimeInspected: false, running: false }; } + const runtimeStatus = serviceState.runtime?.status; + const runtimeInspected = runtimeStatus === "running" || runtimeStatus === "stopped"; if (!serviceState.installed) { - return { stopped: false }; + return { + stopped: false, + inspected: true, + runtimeInspected, + running: serviceState.running, + serviceEnv: serviceState.env, + }; } if (!params.shouldRestart) { @@ -190,18 +201,46 @@ async function maybeStopManagedServiceBeforePackageUpdate(params: { ), ); } - return { stopped: false, serviceEnv: serviceState.env }; + return { + stopped: false, + inspected: true, + runtimeInspected, + running: serviceState.running, + serviceEnv: serviceState.env, + }; + } + + if (!runtimeInspected) { + return { + stopped: false, + inspected: true, + runtimeInspected: false, + running: false, + serviceEnv: serviceState.env, + }; } if (!serviceState.running) { - return { stopped: false, serviceEnv: serviceState.env }; + return { + stopped: false, + inspected: true, + runtimeInspected: true, + running: false, + serviceEnv: serviceState.env, + }; } if (!params.jsonMode) { defaultRuntime.log(theme.muted("Stopping managed gateway service before package update...")); } await service.stop({ env: serviceState.env, stdout: process.stdout }); - return { stopped: true, serviceEnv: serviceState.env }; + return { + stopped: true, + inspected: true, + runtimeInspected: true, + running: true, + serviceEnv: serviceState.env, + }; } async function maybeRestartServiceAfterFailedPackageUpdate(params: { @@ -239,6 +278,25 @@ function isRunningInsideGatewayService( return !serviceKind || serviceKind === GATEWAY_SERVICE_KIND; } +function shouldBlockPackageUpdateFromGatewayServiceEnv(params: { + prePackageServiceStop: PrePackageServiceStop | undefined; +}): boolean { + if (!isRunningInsideGatewayService()) { + return false; + } + const stopState = params.prePackageServiceStop; + if (!stopState?.inspected) { + return true; + } + if (stopState.stopped) { + return false; + } + if (!stopState.runtimeInspected) { + return true; + } + return stopState.running; +} + function formatCommandFailure(stdout: string, stderr: string): string { const detail = (stderr || stdout).trim(); if (!detail) { @@ -317,6 +375,13 @@ function disableUpdatedPackageCompileCacheEnv(env: NodeJS.ProcessEnv): NodeJS.Pr }; } +function stripGatewayServiceMarkerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const resolvedEnv = { ...env }; + delete resolvedEnv.OPENCLAW_SERVICE_MARKER; + delete resolvedEnv.OPENCLAW_SERVICE_KIND; + return resolvedEnv; +} + function resolveUpdatedInstallCommandEnv( env: NodeJS.ProcessEnv, invocationCwd?: string, @@ -1271,7 +1336,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { const child = spawn(resolveNodeRunner(), argv, { stdio: "inherit", env: { - ...disableUpdatedPackageCompileCacheEnv(process.env), + ...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), [POST_CORE_UPDATE_ENV]: "1", [POST_CORE_UPDATE_CHANNEL_ENV]: params.channel, ...(params.requestedChannel @@ -1555,18 +1620,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - if (updateInstallKind === "package" && isRunningInsideGatewayService()) { - defaultRuntime.error( - [ - "Package updates cannot run from inside the gateway service process.", - "That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.", - `Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`, - ].join("\n"), - ); - defaultRuntime.exit(1); - return; - } - if (downgradeRisk && !opts.yes) { if (!process.stdin.isTTY || opts.json) { defaultRuntime.error( @@ -1634,6 +1687,19 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.exit(1); return; } + + if (shouldBlockPackageUpdateFromGatewayServiceEnv({ prePackageServiceStop })) { + stop(); + defaultRuntime.error( + [ + "Package updates cannot run from inside the gateway service process.", + "That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.", + `Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`, + ].join("\n"), + ); + defaultRuntime.exit(1); + return; + } } let result: UpdateRunResult;