fix(onboarding): pin health auth during setup

This commit is contained in:
Peter Steinberger
2026-04-28 08:51:24 +01:00
parent 8b4a5d70e4
commit 2d575bc00e
7 changed files with 182 additions and 25 deletions

View File

@@ -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.

View File

@@ -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",
}),
);
});

View File

@@ -21,6 +21,7 @@ async function writeSecureFile(filePath: string, content: string): Promise<void>
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" });
});
});

View File

@@ -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,

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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 <T>(
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,