From 2d575bc00e900d8952dd052f1ac80412561cb06e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 08:51:24 +0100 Subject: [PATCH] fix(onboarding): pin health auth during setup --- CHANGELOG.md | 2 +- src/commands/health.test.ts | 9 ++- ...on-interactive.gateway-health-auth.test.ts | 26 ++++++++ .../onboard-non-interactive.gateway.test.ts | 38 +++++++++++ src/commands/onboard-non-interactive/local.ts | 21 +++++- src/wizard/setup.finalize.test.ts | 65 +++++++++++++++++++ src/wizard/setup.finalize.ts | 46 ++++++------- 7 files changed, 182 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78845c8804..ff030f64816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk. - Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto. - Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston. -- Onboarding: pin the final QuickStart health check to the just-configured setup token so stale `OPENCLAW_GATEWAY_TOKEN` values or older config tokens do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev. +- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev. - Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9. - Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby. - Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar. diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index db36711ce8a..4ff32c2bb35 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -106,7 +106,13 @@ describe("healthCommand", () => { callGatewayMock.mockResolvedValueOnce(snapshot); await healthCommand( - { json: true, timeoutMs: 5000, config: {}, token: "setup-token" }, + { + json: true, + timeoutMs: 5000, + config: {}, + token: "setup-token", + password: "setup-password", + }, runtime as never, ); @@ -114,6 +120,7 @@ describe("healthCommand", () => { expect.objectContaining({ method: "health", token: "setup-token", + password: "setup-password", }), ); }); diff --git a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts index a4705a56c39..9a8f6012ee9 100644 --- a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts +++ b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts @@ -21,6 +21,7 @@ async function writeSecureFile(filePath: string, content: string): Promise describe("resolveGatewayHealthProbeToken", () => { const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; + const originalGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; afterEach(() => { if (originalGatewayToken === undefined) { @@ -28,6 +29,11 @@ describe("resolveGatewayHealthProbeToken", () => { } else { process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken; } + if (originalGatewayPassword === undefined) { + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + } else { + process.env.OPENCLAW_GATEWAY_PASSWORD = originalGatewayPassword; + } }); it("resolves file SecretRefs for the local onboarding health probe without persisting plaintext", async () => { @@ -92,4 +98,24 @@ describe("resolveGatewayHealthProbeToken", () => { expect(resolved.unresolvedRefReason).toContain("gateway.auth.token SecretRef is unresolved"); }); }); + + it("resolves password auth for the local onboarding health probe", async () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "stale-env-token"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-password"; // pragma: allowlist secret + + const resolved = await resolveGatewayHealthProbeToken({ + gateway: { + auth: { + mode: "password", + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, + }, + }, + } as OpenClawConfig); + + expect(resolved).toEqual({ password: "resolved-password" }); + }); }); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 08707595096..68f39dca53a 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -456,6 +456,44 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("passes pinned gateway auth through non-interactive health checks", async () => { + await withStateDir("state-local-daemon-health-auth-", async (stateDir) => { + const token = "tok_noninteractive_health"; + waitForGatewayReachableMock = vi.fn(async () => ({ ok: true })); + + await runNonInteractiveSetup( + { + ...createLocalDaemonSetupOptions(stateDir), + gatewayAuth: "token", + gatewayToken: token, + }, + runtime, + ); + + expect(waitForGatewayReachableMock).toHaveBeenCalledWith( + expect.objectContaining({ + token, + password: undefined, + }), + ); + expect(healthCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + token, + password: undefined, + config: expect.objectContaining({ + gateway: expect.objectContaining({ + auth: expect.objectContaining({ + mode: "token", + token, + }), + }), + }), + }), + expect.any(Object), + ); + }); + }, 60_000); + it("uses longer Windows health timings for daemon install probes", () => { expect(resolveInstallDaemonGatewayHealthTiming("win32")).toEqual({ deadlineMs: 90_000, diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index cdcde4c0bad..e13871f0fa3 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -3,6 +3,7 @@ import { replaceConfigFile, resolveGatewayPort } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveGatewayAuthToken } from "../../gateway/auth-token-resolution.js"; +import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js"; import type { RuntimeEnv } from "../../runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js"; import { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig } from "../onboard-config.js"; @@ -95,7 +96,21 @@ async function collectGatewayHealthFailureDiagnostics(): Promise< export async function resolveGatewayHealthProbeToken( nextConfig: OpenClawConfig, -): Promise<{ token?: string; unresolvedRefReason?: string }> { +): Promise<{ token?: string; password?: string; unresolvedRefReason?: string }> { + if (nextConfig.gateway?.auth?.mode === "password") { + const resolved = await resolveConfiguredSecretInputString({ + config: nextConfig, + env: process.env, + value: nextConfig.gateway.auth.password, + path: "gateway.auth.password", + unresolvedReasonStyle: "detailed", + }); + return { + password: resolved.value, + unresolvedRefReason: resolved.unresolvedRefReason, + }; + } + const resolved = await resolveGatewayAuthToken({ cfg: nextConfig, env: process.env, @@ -269,6 +284,7 @@ export async function runNonInteractiveLocalSetup(params: { const probe = await waitForGatewayReachable({ url: links.wsUrl, token: probeAuth.token, + password: probeAuth.password, deadlineMs: opts.installDaemon ? installDaemonGatewayHealthTiming.deadlineMs : ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS, @@ -318,6 +334,9 @@ export async function runNonInteractiveLocalSetup(params: { timeoutMs: opts.installDaemon ? installDaemonGatewayHealthTiming.healthCommandTimeoutMs : 10_000, + config: nextConfig, + token: probeAuth.token, + password: probeAuth.password, }, runtime, ); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 78c2aa17786..ad63061107f 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -614,6 +614,71 @@ describe("finalizeSetupWizard", () => { ); }); + it("uses the resolved setup password for health checks", async () => { + vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "env-password"); + resolveSetupSecretInputString.mockResolvedValueOnce("session-password"); + const prompter = createLaterPrompter(); + + await finalizeSetupWizard({ + flow: "quickstart", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: false, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + gateway: { + auth: { + mode: "password", + password: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_PASSWORD", + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "password", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(waitForGatewayReachable).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + token: undefined, + password: "session-password", + }), + ); + expect(healthCommand).toHaveBeenCalledWith( + expect.objectContaining({ + json: false, + timeoutMs: 10_000, + token: undefined, + password: "session-password", + config: expect.objectContaining({ + gateway: expect.objectContaining({ + auth: expect.objectContaining({ + mode: "password", + }), + }), + }), + }), + expect.any(Object), + ); + }); + it("shows actionable gateway guidance instead of a hard error in no-daemon onboarding", async () => { waitForGatewayReachable.mockResolvedValue({ ok: false, diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 477b4909dde..104c6bb1ece 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -63,6 +63,7 @@ export async function finalizeSetupWizard( ): Promise<{ launchedTui: boolean }> { const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options; let gatewayProbe: { ok: boolean; detail?: string } = { ok: true }; + let resolvedGatewayPassword = ""; const withWizardProgress = async ( label: string, @@ -236,6 +237,26 @@ export async function finalizeSetupWizard( } } + if (settings.authMode === "password") { + try { + resolvedGatewayPassword = + (await resolveSetupSecretInputString({ + config: nextConfig, + value: nextConfig.gateway?.auth?.password, + path: "gateway.auth.password", + env: process.env, + })) ?? ""; + } catch (error) { + await prompter.note( + [ + "Could not resolve gateway.auth.password SecretRef for setup auth.", + formatErrorMessage(error), + ].join("\n"), + "Gateway auth", + ); + } + } + if (!opts.skipHealth) { const probeLinks = resolveControlUiLinks({ bind: nextConfig.gateway?.bind ?? "loopback", @@ -247,7 +268,8 @@ export async function finalizeSetupWizard( // Daemon install/restart can briefly flap the WS; wait a bit so health check doesn't false-fail. gatewayProbe = await waitForGatewayReachable({ url: probeLinks.wsUrl, - token: settings.gatewayToken, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, + password: settings.authMode === "password" ? resolvedGatewayPassword : undefined, deadlineMs: 15_000, }); if (gatewayProbe.ok) { @@ -272,6 +294,7 @@ export async function finalizeSetupWizard( timeoutMs: 10_000, config: healthConfig, token: settings.authMode === "token" ? settings.gatewayToken : undefined, + password: settings.authMode === "password" ? resolvedGatewayPassword : undefined, }, runtime, ); @@ -348,27 +371,6 @@ export async function finalizeSetupWizard( settings.authMode === "token" && settings.gatewayToken ? `${links.httpUrl}#token=${encodeURIComponent(settings.gatewayToken)}` : links.httpUrl; - let resolvedGatewayPassword = ""; - if (settings.authMode === "password") { - try { - resolvedGatewayPassword = - (await resolveSetupSecretInputString({ - config: nextConfig, - value: nextConfig.gateway?.auth?.password, - path: "gateway.auth.password", - env: process.env, - })) ?? ""; - } catch (error) { - await prompter.note( - [ - "Could not resolve gateway.auth.password SecretRef for setup auth.", - formatErrorMessage(error), - ].join("\n"), - "Gateway auth", - ); - } - } - if (opts.skipHealth || !gatewayProbe.ok) { gatewayProbe = await probeGatewayReachable({ url: links.wsUrl,