Skip to content

Commit b546aa9

Browse files
committed
fix(update): authenticate restart health probes
1 parent a373468 commit b546aa9

3 files changed

Lines changed: 128 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai
7272
- Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as `<media:image>`. Fixes #69793. Thanks @aspalagin.
7373
- Agents/cache: keep per-turn runtime context out of ordinary chat system prompts while still delivering hidden current-turn context, restoring prompt-cache reuse on chat continuations. Fixes #77431. Thanks @Udjin79.
7474
- Gateway/startup: include resolved thinking and fast-mode defaults in the `agent model` startup log line, defaulting unset startup thinking to `medium` without mixing in reasoning visibility.
75+
- Gateway/update: resolve local gateway probe auth from the installed config during post-update restart verification, so token/device-authenticated VPS gateways are not misreported as unhealthy port conflicts after a package swap. Thanks @vincentkoc.
7576
- Agents/Tools: add post-compaction loop guard in `pi-embedded-runner` that arms after auto-compaction-retry and aborts the run with `compaction_loop_persisted` when the agent emits the same `(tool, args, result)` triple `windowSize` times (default 3) within that window. Disable via existing `tools.loopDetection.enabled`; tune via `tools.loopDetection.postCompactionGuard.windowSize`. Targets the failure mode where context-overflow + compaction does not break a tool-call loop. Refs #77474; carries forward #21597. Thanks @efpiva.
7677
- Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces.
7778
- Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible.

src/cli/daemon-cli/restart-health.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ const classifyPortListener = vi.hoisted(() =>
88
vi.fn<(_listener: unknown, _port: number) => PortListenerKind>(() => "gateway"),
99
);
1010
const probeGateway = vi.hoisted(() => vi.fn());
11+
const readBestEffortConfig = vi.hoisted(() => vi.fn(async () => ({})));
12+
const resolveGatewayProbeAuthSafeWithSecretInputs = vi.hoisted(() =>
13+
vi.fn<(_opts: unknown) => Promise<{ auth: { token?: string; password?: string } }>>(async () => ({
14+
auth: {},
15+
})),
16+
);
1117

1218
vi.mock("../../infra/ports.js", () => ({
1319
classifyPortListener: (listener: unknown, port: number) => classifyPortListener(listener, port),
@@ -19,6 +25,17 @@ vi.mock("../../gateway/probe.js", () => ({
1925
probeGateway: (opts: unknown) => probeGateway(opts),
2026
}));
2127

28+
vi.mock("../../config/io.js", () => ({
29+
createConfigIO: () => ({
30+
readBestEffortConfig: () => readBestEffortConfig(),
31+
}),
32+
}));
33+
34+
vi.mock("../../gateway/probe-auth.js", () => ({
35+
resolveGatewayProbeAuthSafeWithSecretInputs: (opts: unknown) =>
36+
resolveGatewayProbeAuthSafeWithSecretInputs(opts),
37+
}));
38+
2239
vi.mock("../../utils.js", async () => {
2340
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
2441
return {
@@ -112,6 +129,10 @@ async function waitForStoppedFreeGatewayRestart() {
112129
describe("inspectGatewayRestart", () => {
113130
beforeEach(() => {
114131
inspectPortUsage.mockReset();
132+
readBestEffortConfig.mockReset();
133+
readBestEffortConfig.mockResolvedValue({});
134+
resolveGatewayProbeAuthSafeWithSecretInputs.mockReset();
135+
resolveGatewayProbeAuthSafeWithSecretInputs.mockResolvedValue({ auth: {} });
115136
inspectPortUsage.mockResolvedValue({
116137
port: 0,
117138
status: "free",
@@ -380,6 +401,52 @@ describe("inspectGatewayRestart", () => {
380401
expect(snapshot.versionMismatch).toBeUndefined();
381402
});
382403

404+
it("uses configured local probe auth while waiting for a matching-version restart", async () => {
405+
readBestEffortConfig.mockResolvedValue({
406+
gateway: { auth: { mode: "token", token: "probe-token" } },
407+
});
408+
resolveGatewayProbeAuthSafeWithSecretInputs.mockResolvedValue({
409+
auth: { token: "probe-token" },
410+
});
411+
probeGateway.mockResolvedValue({
412+
ok: true,
413+
close: null,
414+
server: { version: "2026.4.24", connId: "new" },
415+
});
416+
const service = makeGatewayService({ status: "running", pid: 8000 });
417+
inspectPortUsage.mockResolvedValue({
418+
port: 18789,
419+
status: "busy",
420+
listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }],
421+
hints: [],
422+
});
423+
424+
const { waitForGatewayHealthyRestart } = await import("./restart-health.js");
425+
const snapshot = await waitForGatewayHealthyRestart({
426+
service,
427+
port: 18789,
428+
expectedVersion: "2026.4.24",
429+
attempts: 1,
430+
});
431+
432+
expect(snapshot).toMatchObject({
433+
healthy: true,
434+
gatewayVersion: "2026.4.24",
435+
expectedVersion: "2026.4.24",
436+
});
437+
expect(resolveGatewayProbeAuthSafeWithSecretInputs).toHaveBeenCalledWith(
438+
expect.objectContaining({
439+
cfg: { gateway: { auth: { mode: "token", token: "probe-token" } } },
440+
mode: "local",
441+
}),
442+
);
443+
expect(probeGateway).toHaveBeenCalledWith(
444+
expect.objectContaining({
445+
auth: { token: "probe-token", password: undefined },
446+
}),
447+
);
448+
});
449+
383450
it("stops waiting once the restarted gateway reports the wrong version", async () => {
384451
probeGateway.mockResolvedValue({
385452
ok: true,

src/cli/daemon-cli/restart-health.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { PluginHealthErrorSummary } from "../../commands/health.types.js";
2+
import { createConfigIO } from "../../config/io.js";
3+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
24
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
35
import type { GatewayService } from "../../daemon/service.js";
6+
import { resolveGatewayProbeAuthSafeWithSecretInputs } from "../../gateway/probe-auth.js";
47
import { probeGateway } from "../../gateway/probe.js";
58
import {
69
classifyPortListener,
@@ -61,6 +64,11 @@ type GatewayReachability = {
6164
channelProbeErrors: Array<{ id: string; error: string }>;
6265
};
6366

67+
type GatewayRestartProbeAuth = {
68+
token?: string;
69+
password?: string;
70+
};
71+
6472
function hasListenerAttributionGap(portUsage: PortUsage): boolean {
6573
if (portUsage.status !== "busy" || portUsage.listeners.length > 0) {
6674
return false;
@@ -228,9 +236,12 @@ function applyChannelProbeErrors(snapshot: GatewayRestartSnapshot): GatewayResta
228236
async function confirmGatewayReachable(params: {
229237
port: number;
230238
includeHealthDetails?: boolean;
239+
auth?: GatewayRestartProbeAuth;
231240
}): Promise<GatewayReachability> {
232-
const token = normalizeOptionalString(process.env.OPENCLAW_GATEWAY_TOKEN);
233-
const password = normalizeOptionalString(process.env.OPENCLAW_GATEWAY_PASSWORD);
241+
const token = normalizeOptionalString(params.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN);
242+
const password = normalizeOptionalString(
243+
params.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD,
244+
);
234245
const probe = await probeGateway({
235246
url: `ws://127.0.0.1:${params.port}`,
236247
auth: token || password ? { token, password } : undefined,
@@ -251,13 +262,37 @@ async function confirmGatewayReachable(params: {
251262
};
252263
}
253264

254-
async function inspectGatewayPortHealth(port: number): Promise<GatewayPortHealthSnapshot> {
265+
async function resolveGatewayRestartProbeAuth(
266+
env: NodeJS.ProcessEnv | undefined,
267+
): Promise<GatewayRestartProbeAuth | undefined> {
268+
const mergedEnv = {
269+
...(process.env as Record<string, string | undefined>),
270+
...(env ?? undefined),
271+
} as NodeJS.ProcessEnv;
272+
const cfg = await createConfigIO({
273+
env: mergedEnv,
274+
pluginValidation: "skip",
275+
})
276+
.readBestEffortConfig()
277+
.catch((): OpenClawConfig => ({}));
278+
const resolved = await resolveGatewayProbeAuthSafeWithSecretInputs({
279+
cfg,
280+
mode: "local",
281+
env: mergedEnv,
282+
});
283+
return resolved.auth;
284+
}
285+
286+
async function inspectGatewayPortHealth(params: {
287+
port: number;
288+
auth?: GatewayRestartProbeAuth;
289+
}): Promise<GatewayPortHealthSnapshot> {
255290
let portUsage: PortUsage;
256291
try {
257-
portUsage = await inspectPortUsage(port);
292+
portUsage = await inspectPortUsage(params.port);
258293
} catch (err) {
259294
portUsage = {
260-
port,
295+
port: params.port,
261296
status: "unknown",
262297
listeners: [],
263298
hints: [],
@@ -268,7 +303,12 @@ async function inspectGatewayPortHealth(port: number): Promise<GatewayPortHealth
268303
let healthy = false;
269304
if (portUsage.status === "busy") {
270305
try {
271-
healthy = (await confirmGatewayReachable({ port })).reachable;
306+
healthy = (
307+
await confirmGatewayReachable({
308+
port: params.port,
309+
auth: params.auth,
310+
})
311+
).reachable;
272312
} catch {
273313
// best-effort probe
274314
}
@@ -283,6 +323,7 @@ export async function inspectGatewayRestart(params: {
283323
env?: NodeJS.ProcessEnv;
284324
expectedVersion?: string | null;
285325
includeUnknownListenersAsStale?: boolean;
326+
probeAuth?: GatewayRestartProbeAuth;
286327
}): Promise<GatewayRestartSnapshot> {
287328
const env = params.env ?? process.env;
288329
const expectedVersion = normalizeOptionalString(params.expectedVersion);
@@ -294,6 +335,7 @@ export async function inspectGatewayRestart(params: {
294335
reachability = await confirmGatewayReachable({
295336
port: params.port,
296337
includeHealthDetails: Boolean(expectedVersion),
338+
auth: params.probeAuth,
297339
});
298340
activatedPluginErrors = reachability.activatedPluginErrors;
299341
channelProbeErrors = reachability.channelProbeErrors;
@@ -477,12 +519,14 @@ export async function waitForGatewayHealthyRestart(params: {
477519
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
478520
const delayMs = params.delayMs ?? DEFAULT_RESTART_HEALTH_DELAY_MS;
479521

522+
const probeAuth = await resolveGatewayRestartProbeAuth(params.env).catch(() => undefined);
480523
let snapshot = await inspectGatewayRestart({
481524
service: params.service,
482525
port: params.port,
483526
env: params.env,
484527
expectedVersion: params.expectedVersion,
485528
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
529+
probeAuth,
486530
});
487531

488532
let consecutiveStoppedFreeCount = 0;
@@ -523,6 +567,7 @@ export async function waitForGatewayHealthyRestart(params: {
523567
env: params.env,
524568
expectedVersion: params.expectedVersion,
525569
includeUnknownListenersAsStale: params.includeUnknownListenersAsStale,
570+
probeAuth,
526571
});
527572
}
528573

@@ -537,14 +582,21 @@ export async function waitForGatewayHealthyListener(params: {
537582
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
538583
const delayMs = params.delayMs ?? DEFAULT_RESTART_HEALTH_DELAY_MS;
539584

540-
let snapshot = await inspectGatewayPortHealth(params.port);
585+
const probeAuth = await resolveGatewayRestartProbeAuth(undefined).catch(() => undefined);
586+
let snapshot = await inspectGatewayPortHealth({
587+
port: params.port,
588+
auth: probeAuth,
589+
});
541590

542591
for (let attempt = 0; attempt < attempts; attempt += 1) {
543592
if (snapshot.healthy) {
544593
return snapshot;
545594
}
546595
await sleep(delayMs);
547-
snapshot = await inspectGatewayPortHealth(params.port);
596+
snapshot = await inspectGatewayPortHealth({
597+
port: params.port,
598+
auth: probeAuth,
599+
});
548600
}
549601

550602
return snapshot;

0 commit comments

Comments
 (0)