diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 79ea19c974b..f1d61e0f6dd 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -612,6 +612,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,7 +1281,7 @@ 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")); await withEnvAsync( @@ -1276,12 +1294,42 @@ describe("update-cli", () => { }, ); + 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"], @@ -1605,7 +1653,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..2a52b31b011 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -162,6 +162,8 @@ export function shouldUseLegacyProcessRestartAfterUpdate(params: { type PrePackageServiceStop = { stopped: boolean; + inspected: boolean; + running: boolean; serviceEnv?: NodeJS.ProcessEnv; }; @@ -175,11 +177,11 @@ async function maybeStopManagedServiceBeforePackageUpdate(params: { service = resolveGatewayService(); serviceState = await readGatewayServiceState(service, { env: process.env }); } catch { - return { stopped: false }; + return { stopped: false, inspected: false, running: false }; } if (!serviceState.installed) { - return { stopped: false }; + return { stopped: false, inspected: true, running: false }; } if (!params.shouldRestart) { @@ -190,18 +192,23 @@ async function maybeStopManagedServiceBeforePackageUpdate(params: { ), ); } - return { stopped: false, serviceEnv: serviceState.env }; + return { + stopped: false, + inspected: true, + running: serviceState.running, + serviceEnv: serviceState.env, + }; } if (!serviceState.running) { - return { stopped: false, serviceEnv: serviceState.env }; + return { stopped: false, inspected: 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, running: true, serviceEnv: serviceState.env }; } async function maybeRestartServiceAfterFailedPackageUpdate(params: { @@ -239,6 +246,22 @@ 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.running) { + return false; + } + return !stopState.stopped; +} + function formatCommandFailure(stdout: string, stderr: string): string { const detail = (stderr || stdout).trim(); if (!detail) { @@ -317,6 +340,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 +1301,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 +1585,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 +1652,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;