Skip to content

Commit 0b09cfb

Browse files
authored
fix(cli): block package updates from inside running gateway service (#75729)
Merged via squash. Prepared head SHA: 8f301c5 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819
1 parent 473fc0a commit 0b09cfb

3 files changed

Lines changed: 240 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
Docs: https://docs.openclaw.ai
44

5+
## Unreleased
6+
7+
### Changes
8+
9+
### Fixes
10+
11+
- 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.
12+
513
## 2026.5.2
614

715
### Changes
@@ -267,6 +275,7 @@ Docs: https://docs.openclaw.ai
267275
- 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.
268276
- 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.
269277

278+
270279
## 2026.4.30
271280

272281
### Changes

src/cli/update-cli.test.ts

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,10 @@ vi.mock("../daemon/service.js", () => ({
184184
? (command.environment as NodeJS.ProcessEnv | undefined)
185185
: undefined),
186186
};
187-
const [loaded, runtime] = await Promise.all([serviceLoaded({ env }), serviceReadRuntime(env)]);
187+
const [loaded, runtime] = await Promise.all([
188+
serviceLoaded({ env }).catch(() => false),
189+
serviceReadRuntime(env).catch(() => undefined),
190+
]);
188191
return {
189192
installed: command !== null,
190193
loaded,
@@ -612,6 +615,24 @@ describe("update-cli", () => {
612615
expect(runDaemonRestart).not.toHaveBeenCalled();
613616
});
614617

618+
it("does not carry gateway service markers into the post-core update process", async () => {
619+
setupUpdatedRootRefresh();
620+
621+
await withEnvAsync(
622+
{
623+
OPENCLAW_SERVICE_MARKER: "openclaw",
624+
OPENCLAW_SERVICE_KIND: "gateway",
625+
},
626+
async () => {
627+
await updateCommand({ yes: true });
628+
},
629+
);
630+
631+
const spawnEnv = (spawn.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env;
632+
expect(spawnEnv?.OPENCLAW_SERVICE_MARKER).toBeUndefined();
633+
expect(spawnEnv?.OPENCLAW_SERVICE_KIND).toBeUndefined();
634+
});
635+
615636
it("respawns into the updated git root before requested channel persistence", async () => {
616637
const { entrypoints } = setupUpdatedRootRefresh({
617638
gatewayUpdateImpl: async (root) =>
@@ -1263,8 +1284,122 @@ describe("update-cli", () => {
12631284
).toContain("Low disk space near");
12641285
});
12651286

1266-
it("refuses package updates from inside the gateway service process", async () => {
1287+
it("allows package updates from inherited gateway service env when the managed gateway is not running", async () => {
1288+
mockPackageInstallStatus(createCaseDir("openclaw-update"));
1289+
serviceReadRuntime.mockResolvedValueOnce({
1290+
status: "stopped",
1291+
state: "stopped",
1292+
});
1293+
1294+
await withEnvAsync(
1295+
{
1296+
OPENCLAW_SERVICE_MARKER: "openclaw",
1297+
OPENCLAW_SERVICE_KIND: "gateway",
1298+
},
1299+
async () => {
1300+
await updateCommand({ yes: true });
1301+
},
1302+
);
1303+
1304+
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
1305+
expect.stringContaining(
1306+
"Package updates cannot run from inside the gateway service process.",
1307+
),
1308+
);
1309+
expectPackageInstallSpec("openclaw@latest");
1310+
});
1311+
1312+
it("refuses package updates from inherited gateway service env when --no-restart leaves the gateway running", async () => {
1313+
mockPackageInstallStatus(createCaseDir("openclaw-update"));
1314+
serviceReadCommand.mockResolvedValue({
1315+
programArguments: ["openclaw", "gateway", "run"],
1316+
environment: {
1317+
OPENCLAW_SERVICE_MARKER: "openclaw",
1318+
OPENCLAW_SERVICE_KIND: "gateway",
1319+
},
1320+
});
1321+
serviceLoaded.mockResolvedValue(true);
1322+
1323+
await withEnvAsync(
1324+
{
1325+
OPENCLAW_SERVICE_MARKER: "openclaw",
1326+
OPENCLAW_SERVICE_KIND: "gateway",
1327+
},
1328+
async () => {
1329+
await updateCommand({ yes: true, restart: false });
1330+
},
1331+
);
1332+
1333+
expect(defaultRuntime.error).toHaveBeenCalledWith(
1334+
expect.stringContaining(
1335+
"Package updates cannot run from inside the gateway service process.",
1336+
),
1337+
);
1338+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
1339+
expect(serviceStop).not.toHaveBeenCalled();
1340+
expect(runGatewayUpdate).not.toHaveBeenCalled();
1341+
expect(runCommandWithTimeout).not.toHaveBeenCalledWith(
1342+
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
1343+
expect.any(Object),
1344+
);
1345+
});
1346+
1347+
it.each([
1348+
{
1349+
name: "runtime probe fails",
1350+
setupRuntime: () =>
1351+
serviceReadRuntime.mockRejectedValueOnce(new Error("runtime probe failed")),
1352+
},
1353+
{
1354+
name: "runtime status is unknown",
1355+
setupRuntime: () => serviceReadRuntime.mockResolvedValueOnce({ status: "unknown" }),
1356+
},
1357+
])(
1358+
"refuses package updates from inherited gateway service env when $name",
1359+
async ({ setupRuntime }) => {
1360+
mockPackageInstallStatus(createCaseDir("openclaw-update"));
1361+
serviceReadCommand.mockResolvedValue({
1362+
programArguments: ["openclaw", "gateway", "run"],
1363+
environment: {
1364+
OPENCLAW_SERVICE_MARKER: "openclaw",
1365+
OPENCLAW_SERVICE_KIND: "gateway",
1366+
},
1367+
});
1368+
setupRuntime();
1369+
1370+
await withEnvAsync(
1371+
{
1372+
OPENCLAW_SERVICE_MARKER: "openclaw",
1373+
OPENCLAW_SERVICE_KIND: "gateway",
1374+
},
1375+
async () => {
1376+
await updateCommand({ yes: true });
1377+
},
1378+
);
1379+
1380+
expect(defaultRuntime.error).toHaveBeenCalledWith(
1381+
expect.stringContaining(
1382+
"Package updates cannot run from inside the gateway service process.",
1383+
),
1384+
);
1385+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
1386+
expect(serviceStop).not.toHaveBeenCalled();
1387+
expect(runGatewayUpdate).not.toHaveBeenCalled();
1388+
expect(runCommandWithTimeout).not.toHaveBeenCalledWith(
1389+
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
1390+
expect.any(Object),
1391+
);
1392+
},
1393+
);
1394+
1395+
it("refuses package updates from inherited gateway service env when the service definition is missing but runtime is live", async () => {
12671396
mockPackageInstallStatus(createCaseDir("openclaw-update"));
1397+
serviceReadCommand.mockResolvedValue(null);
1398+
serviceReadRuntime.mockResolvedValueOnce({
1399+
status: "running",
1400+
pid: 4242,
1401+
state: "running",
1402+
});
12681403

12691404
await withEnvAsync(
12701405
{
@@ -1282,6 +1417,7 @@ describe("update-cli", () => {
12821417
),
12831418
);
12841419
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
1420+
expect(serviceStop).not.toHaveBeenCalled();
12851421
expect(runGatewayUpdate).not.toHaveBeenCalled();
12861422
expect(runCommandWithTimeout).not.toHaveBeenCalledWith(
12871423
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
@@ -1605,7 +1741,15 @@ describe("update-cli", () => {
16051741
};
16061742
});
16071743

1608-
await updateCommand({ yes: true });
1744+
await withEnvAsync(
1745+
{
1746+
OPENCLAW_SERVICE_MARKER: "openclaw",
1747+
OPENCLAW_SERVICE_KIND: "gateway",
1748+
},
1749+
async () => {
1750+
await updateCommand({ yes: true });
1751+
},
1752+
);
16091753

16101754
const npmInstallCallIndex = vi
16111755
.mocked(runCommandWithTimeout)

src/cli/update-cli/update-command.ts

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ export function shouldUseLegacyProcessRestartAfterUpdate(params: {
162162

163163
type PrePackageServiceStop = {
164164
stopped: boolean;
165+
inspected: boolean;
166+
runtimeInspected: boolean;
167+
running: boolean;
165168
serviceEnv?: NodeJS.ProcessEnv;
166169
};
167170

@@ -175,11 +178,19 @@ async function maybeStopManagedServiceBeforePackageUpdate(params: {
175178
service = resolveGatewayService();
176179
serviceState = await readGatewayServiceState(service, { env: process.env });
177180
} catch {
178-
return { stopped: false };
181+
return { stopped: false, inspected: false, runtimeInspected: false, running: false };
179182
}
180183

184+
const runtimeStatus = serviceState.runtime?.status;
185+
const runtimeInspected = runtimeStatus === "running" || runtimeStatus === "stopped";
181186
if (!serviceState.installed) {
182-
return { stopped: false };
187+
return {
188+
stopped: false,
189+
inspected: true,
190+
runtimeInspected,
191+
running: serviceState.running,
192+
serviceEnv: serviceState.env,
193+
};
183194
}
184195

185196
if (!params.shouldRestart) {
@@ -190,18 +201,46 @@ async function maybeStopManagedServiceBeforePackageUpdate(params: {
190201
),
191202
);
192203
}
193-
return { stopped: false, serviceEnv: serviceState.env };
204+
return {
205+
stopped: false,
206+
inspected: true,
207+
runtimeInspected,
208+
running: serviceState.running,
209+
serviceEnv: serviceState.env,
210+
};
211+
}
212+
213+
if (!runtimeInspected) {
214+
return {
215+
stopped: false,
216+
inspected: true,
217+
runtimeInspected: false,
218+
running: false,
219+
serviceEnv: serviceState.env,
220+
};
194221
}
195222

196223
if (!serviceState.running) {
197-
return { stopped: false, serviceEnv: serviceState.env };
224+
return {
225+
stopped: false,
226+
inspected: true,
227+
runtimeInspected: true,
228+
running: false,
229+
serviceEnv: serviceState.env,
230+
};
198231
}
199232

200233
if (!params.jsonMode) {
201234
defaultRuntime.log(theme.muted("Stopping managed gateway service before package update..."));
202235
}
203236
await service.stop({ env: serviceState.env, stdout: process.stdout });
204-
return { stopped: true, serviceEnv: serviceState.env };
237+
return {
238+
stopped: true,
239+
inspected: true,
240+
runtimeInspected: true,
241+
running: true,
242+
serviceEnv: serviceState.env,
243+
};
205244
}
206245

207246
async function maybeRestartServiceAfterFailedPackageUpdate(params: {
@@ -239,6 +278,25 @@ function isRunningInsideGatewayService(
239278
return !serviceKind || serviceKind === GATEWAY_SERVICE_KIND;
240279
}
241280

281+
function shouldBlockPackageUpdateFromGatewayServiceEnv(params: {
282+
prePackageServiceStop: PrePackageServiceStop | undefined;
283+
}): boolean {
284+
if (!isRunningInsideGatewayService()) {
285+
return false;
286+
}
287+
const stopState = params.prePackageServiceStop;
288+
if (!stopState?.inspected) {
289+
return true;
290+
}
291+
if (stopState.stopped) {
292+
return false;
293+
}
294+
if (!stopState.runtimeInspected) {
295+
return true;
296+
}
297+
return stopState.running;
298+
}
299+
242300
function formatCommandFailure(stdout: string, stderr: string): string {
243301
const detail = (stderr || stdout).trim();
244302
if (!detail) {
@@ -317,6 +375,13 @@ function disableUpdatedPackageCompileCacheEnv(env: NodeJS.ProcessEnv): NodeJS.Pr
317375
};
318376
}
319377

378+
function stripGatewayServiceMarkerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
379+
const resolvedEnv = { ...env };
380+
delete resolvedEnv.OPENCLAW_SERVICE_MARKER;
381+
delete resolvedEnv.OPENCLAW_SERVICE_KIND;
382+
return resolvedEnv;
383+
}
384+
320385
function resolveUpdatedInstallCommandEnv(
321386
env: NodeJS.ProcessEnv,
322387
invocationCwd?: string,
@@ -1271,7 +1336,7 @@ async function continuePostCoreUpdateInFreshProcess(params: {
12711336
const child = spawn(resolveNodeRunner(), argv, {
12721337
stdio: "inherit",
12731338
env: {
1274-
...disableUpdatedPackageCompileCacheEnv(process.env),
1339+
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
12751340
[POST_CORE_UPDATE_ENV]: "1",
12761341
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
12771342
...(params.requestedChannel
@@ -1555,18 +1620,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
15551620
return;
15561621
}
15571622

1558-
if (updateInstallKind === "package" && isRunningInsideGatewayService()) {
1559-
defaultRuntime.error(
1560-
[
1561-
"Package updates cannot run from inside the gateway service process.",
1562-
"That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
1563-
`Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`,
1564-
].join("\n"),
1565-
);
1566-
defaultRuntime.exit(1);
1567-
return;
1568-
}
1569-
15701623
if (downgradeRisk && !opts.yes) {
15711624
if (!process.stdin.isTTY || opts.json) {
15721625
defaultRuntime.error(
@@ -1634,6 +1687,19 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
16341687
defaultRuntime.exit(1);
16351688
return;
16361689
}
1690+
1691+
if (shouldBlockPackageUpdateFromGatewayServiceEnv({ prePackageServiceStop })) {
1692+
stop();
1693+
defaultRuntime.error(
1694+
[
1695+
"Package updates cannot run from inside the gateway service process.",
1696+
"That path replaces the active OpenClaw dist tree while the live gateway may still lazy-load old chunks.",
1697+
`Run \`${replaceCliName(formatCliCommand("openclaw update"), CLI_NAME)}\` from a shell outside the gateway service, or stop the gateway service first and then update.`,
1698+
].join("\n"),
1699+
);
1700+
defaultRuntime.exit(1);
1701+
return;
1702+
}
16371703
}
16381704

16391705
let result: UpdateRunResult;

0 commit comments

Comments
 (0)