fix: resolve onboard health auth from config

This commit is contained in:
Gustavo Madeira Santana
2026-04-16 18:45:25 -04:00
parent 39d18a4b86
commit eea3691ad2
5 changed files with 124 additions and 41 deletions

View File

@@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
## 2026.4.15
### Changes

View File

@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayHealthProbeToken } from "./onboard-non-interactive/local.js";
async function withTempDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-health-auth-"));
try {
return await run(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function writeSecureFile(filePath: string, content: string): Promise<void> {
await fs.writeFile(filePath, content, { mode: 0o600 });
await fs.chmod(filePath, 0o600);
}
describe("resolveGatewayHealthProbeToken", () => {
const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
afterEach(() => {
if (originalGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;
}
});
it("resolves file SecretRefs for the local onboarding health probe without persisting plaintext", async () => {
await withTempDir(async (dir) => {
const tokenPath = path.join(dir, "gateway-token.txt");
await writeSecureFile(tokenPath, "file-secret-token\n");
process.env.OPENCLAW_GATEWAY_TOKEN = "stale-env-token";
const resolved = await resolveGatewayHealthProbeToken({
gateway: {
auth: {
mode: "token",
token: {
source: "file",
provider: "gateway-token-file",
id: "value",
},
},
},
secrets: {
providers: {
"gateway-token-file": {
source: "file",
path: tokenPath,
mode: "singleValue",
},
},
},
} as OpenClawConfig);
expect(resolved).toEqual({ token: "file-secret-token" });
});
});
it("does not fall back to stale OPENCLAW_GATEWAY_TOKEN when a SecretRef is unresolved", async () => {
await withTempDir(async (dir) => {
process.env.OPENCLAW_GATEWAY_TOKEN = "stale-env-token";
const resolved = await resolveGatewayHealthProbeToken({
gateway: {
auth: {
mode: "token",
token: {
source: "file",
provider: "gateway-token-file",
id: "value",
},
},
},
secrets: {
providers: {
"gateway-token-file": {
source: "file",
path: path.join(dir, "missing-token.txt"),
mode: "singleValue",
},
},
},
} as OpenClawConfig);
expect(resolved.token).toBeUndefined();
expect(resolved.unresolvedRefReason).toContain("gateway.auth.token SecretRef is unresolved");
});
});
});

View File

@@ -2,6 +2,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
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 type { RuntimeEnv } from "../../runtime.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js";
import { applyLocalSetupWorkspaceConfig } from "../onboard-config.js";
@@ -91,6 +92,21 @@ async function collectGatewayHealthFailureDiagnostics(): Promise<
: undefined;
}
export async function resolveGatewayHealthProbeToken(
nextConfig: OpenClawConfig,
): Promise<{ token?: string; unresolvedRefReason?: string }> {
const resolved = await resolveGatewayAuthToken({
cfg: nextConfig,
env: process.env,
envFallback: "no-secret-ref",
unresolvedReasonStyle: "detailed",
});
return {
...(resolved.token ? { token: resolved.token } : {}),
...(resolved.unresolvedRefReason ? { unresolvedRefReason: resolved.unresolvedRefReason } : {}),
};
}
export async function runNonInteractiveLocalSetup(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
@@ -230,9 +246,10 @@ export async function runNonInteractiveLocalSetup(params: {
basePath: undefined,
});
const installDaemonGatewayHealthTiming = resolveInstallDaemonGatewayHealthTiming();
const probeAuth = await resolveGatewayHealthProbeToken(nextConfig);
const probe = await waitForGatewayReachable({
url: links.wsUrl,
token: gatewayResult.gatewayToken,
token: probeAuth.token,
deadlineMs: opts.installDaemon
? installDaemonGatewayHealthTiming.deadlineMs
: ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS,
@@ -241,6 +258,8 @@ export async function runNonInteractiveLocalSetup(params: {
: undefined,
});
if (!probe.ok) {
const detail =
[probe.detail, probeAuth.unresolvedRefReason].filter(Boolean).join("\n") || undefined;
const diagnostics = opts.installDaemon
? await collectGatewayHealthFailureDiagnostics()
: undefined;
@@ -250,7 +269,7 @@ export async function runNonInteractiveLocalSetup(params: {
mode,
phase: "gateway-health",
message: `Gateway did not become reachable at ${links.wsUrl}.`,
detail: probe.detail,
detail,
gateway: {
wsUrl: links.wsUrl,
httpUrl: links.httpUrl,

View File

@@ -70,7 +70,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("existing-user-token");
expect(result?.nextConfig.gateway?.auth?.token).toBe("existing-user-token");
expect(randomToken).not.toHaveBeenCalled();
});
@@ -90,7 +89,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("existing-user-token");
expect(result?.nextConfig.gateway?.auth?.token).toBe("existing-user-token");
expect(randomToken).not.toHaveBeenCalled();
});
@@ -107,7 +105,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("flag-token");
expect(result?.nextConfig.gateway?.auth?.token).toBe("flag-token");
expect(randomToken).not.toHaveBeenCalled();
});
@@ -122,7 +119,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("env-token");
expect(result?.nextConfig.gateway?.auth?.token).toBe("env-token");
expect(randomToken).not.toHaveBeenCalled();
});
@@ -136,7 +132,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
});
expect(randomToken).toHaveBeenCalledOnce();
expect(result?.gatewayToken).toBe("generated-random-token");
expect(result?.nextConfig.gateway?.auth?.token).toBe("generated-random-token");
});
@@ -176,9 +171,7 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
expect(randomToken).not.toHaveBeenCalled();
});
it("resolves an env-source SecretRef for the health probe without persisting plaintext", () => {
// For probe/runtime use only: resolve process.env[ref.id] and return it
// as gatewayToken, while leaving the SecretRef intact in config.
it("leaves env-source SecretRef resolution to the health probe path", () => {
process.env[SAMPLE_SECRET_REF.id] = "resolved-secret-value";
const nextConfig = {
gateway: { auth: { mode: "token", token: SAMPLE_SECRET_REF } },
@@ -191,24 +184,8 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("resolved-secret-value");
expect(result?.nextConfig.gateway?.auth?.token).toEqual(SAMPLE_SECRET_REF);
});
it("leaves gatewayToken undefined when an env-source SecretRef is unresolved", () => {
const nextConfig = {
gateway: { auth: { mode: "token", token: SAMPLE_SECRET_REF } },
} as unknown as OpenClawConfig;
const result = applyNonInteractiveGatewayConfig({
nextConfig,
opts: baseOpts,
runtime: createRuntime() as never,
defaultPort: 18789,
});
expect(result?.gatewayToken).toBeUndefined();
expect(result?.nextConfig.gateway?.auth?.token).toEqual(SAMPLE_SECRET_REF);
expect(randomToken).not.toHaveBeenCalled();
});
it("overrides an existing SecretRef when --gateway-token flag is provided", () => {
@@ -223,7 +200,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("flag-token");
expect(result?.nextConfig.gateway?.auth?.token).toBe("flag-token");
expect(randomToken).not.toHaveBeenCalled();
});
@@ -243,7 +219,6 @@ describe("applyNonInteractiveGatewayConfig token resolution chain", () => {
defaultPort: 18789,
});
expect(result?.gatewayToken).toBe("resolved-new-ref-value");
const newToken = result?.nextConfig.gateway?.auth?.token;
expect(newToken).toMatchObject({ source: "env", id: newRefId });
expect(newToken).not.toEqual(SAMPLE_SECRET_REF);

View File

@@ -18,7 +18,6 @@ export function applyNonInteractiveGatewayConfig(params: {
authMode: string;
tailscaleMode: string;
tailscaleResetOnExit: boolean;
gatewayToken?: string;
} | null {
const { opts, runtime } = params;
@@ -121,16 +120,6 @@ export function applyNonInteractiveGatewayConfig(params: {
},
},
};
// Resolve env-source refs inline for the health probe only — do not
// persist any plaintext here. Other ref sources (file/exec) defer to
// the gateway's own resolver; the health probe may then fail unless
// --skip-health is set.
if (existingTokenRef.source === "env") {
const resolved = process.env[existingTokenRef.id]?.trim();
gatewayToken = resolved || undefined;
} else {
gatewayToken = undefined;
}
} else {
if (!gatewayToken) {
gatewayToken = randomToken();
@@ -190,6 +179,5 @@ export function applyNonInteractiveGatewayConfig(params: {
authMode,
tailscaleMode,
tailscaleResetOnExit,
gatewayToken,
};
}