mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix(cli): block package updates from inside running gateway service (#75729)
Merged via squash.
Prepared head SHA: 8f301c5632
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user