diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8d05d8c25..d3c6ad02e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/onboard-non-interactive.gateway-health-auth.test.ts b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts new file mode 100644 index 00000000000..a4705a56c39 --- /dev/null +++ b/src/commands/onboard-non-interactive.gateway-health-auth.test.ts @@ -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(run: (dir: string) => Promise): Promise { + 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 { + 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"); + }); + }); +}); diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index a3830624516..9d848a43aeb 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -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,33 @@ 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", + }); + const probeAuth: { token?: string; unresolvedRefReason?: string } = {}; + if (resolved.token) { + probeAuth.token = resolved.token; + } + if (resolved.unresolvedRefReason) { + probeAuth.unresolvedRefReason = resolved.unresolvedRefReason; + } + return probeAuth; +} + +function formatGatewayHealthFailureDetail(params: { + probeDetail?: string; + unresolvedRefReason?: string; +}): string | undefined { + const detail = [params.probeDetail, params.unresolvedRefReason].filter(Boolean).join("\n"); + return detail || undefined; +} + export async function runNonInteractiveLocalSetup(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -230,9 +258,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 +270,10 @@ export async function runNonInteractiveLocalSetup(params: { : undefined, }); if (!probe.ok) { + const detail = formatGatewayHealthFailureDetail({ + probeDetail: probe.detail, + unresolvedRefReason: probeAuth.unresolvedRefReason, + }); const diagnostics = opts.installDaemon ? await collectGatewayHealthFailureDiagnostics() : undefined; @@ -250,7 +283,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, diff --git a/src/commands/onboard-non-interactive/local/gateway-config.test.ts b/src/commands/onboard-non-interactive/local/gateway-config.test.ts new file mode 100644 index 00000000000..aa4b1b90618 --- /dev/null +++ b/src/commands/onboard-non-interactive/local/gateway-config.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { OnboardOptions } from "../../onboard-types.js"; +import { applyNonInteractiveGatewayConfig } from "./gateway-config.js"; + +// Narrow mock: reproduce normalize semantics (typeof-string + trim, reject +// "undefined"/"null" literals) and stub randomToken so we can assert when a +// fresh token is generated vs. reused from the resolution chain. +const randomToken = vi.hoisted(() => vi.fn(() => "generated-random-token")); +vi.mock("../../onboard-helpers.js", () => ({ + normalizeGatewayTokenInput: (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (trimmed === "undefined" || trimmed === "null") { + return ""; + } + return trimmed; + }, + randomToken, +})); + +function createRuntime() { + return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; +} + +const baseOpts = {} as OnboardOptions; + +const SAMPLE_SECRET_REF = { + source: "env" as const, + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN_REF", +}; + +describe("applyNonInteractiveGatewayConfig token resolution chain", () => { + const originalEnvToken = process.env.OPENCLAW_GATEWAY_TOKEN; + const originalRefValue = process.env[SAMPLE_SECRET_REF.id]; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env[SAMPLE_SECRET_REF.id]; + }); + + afterEach(() => { + if (originalEnvToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = originalEnvToken; + } + if (originalRefValue === undefined) { + delete process.env[SAMPLE_SECRET_REF.id]; + } else { + process.env[SAMPLE_SECRET_REF.id] = originalRefValue; + } + }); + + // --- Plaintext preservation (the original regression) --- + + it("preserves existing plaintext gateway.auth.token when no flag or env override is provided", () => { + const nextConfig = { + gateway: { auth: { mode: "token", token: "existing-user-token" } }, + } as OpenClawConfig; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig, + opts: baseOpts, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + expect(result?.nextConfig.gateway?.auth?.token).toBe("existing-user-token"); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("prefers existing plaintext token over ambient OPENCLAW_GATEWAY_TOKEN on re-onboard", () => { + // A stale shell/launchd OPENCLAW_GATEWAY_TOKEN must not rotate a + // persisted token — that would break already-paired clients. + process.env.OPENCLAW_GATEWAY_TOKEN = "stale-env-token"; + const nextConfig = { + gateway: { auth: { mode: "token", token: "existing-user-token" } }, + } as OpenClawConfig; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig, + opts: baseOpts, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + expect(result?.nextConfig.gateway?.auth?.token).toBe("existing-user-token"); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("prefers --gateway-token flag over existing plaintext token", () => { + const nextConfig = { + gateway: { auth: { mode: "token", token: "existing-user-token" } }, + } as OpenClawConfig; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig, + opts: { gatewayToken: "flag-token" } as OnboardOptions, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + expect(result?.nextConfig.gateway?.auth?.token).toBe("flag-token"); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("uses OPENCLAW_GATEWAY_TOKEN to fill an empty config on first-run", () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig: {} as OpenClawConfig, + opts: baseOpts, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + expect(result?.nextConfig.gateway?.auth?.token).toBe("env-token"); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("generates a random token only when flag, env, and existing config are all empty", () => { + const result = applyNonInteractiveGatewayConfig({ + nextConfig: {} as OpenClawConfig, + opts: baseOpts, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + expect(randomToken).toHaveBeenCalledOnce(); + expect(result?.nextConfig.gateway?.auth?.token).toBe("generated-random-token"); + }); + + // --- SecretRef preservation --- + + it("preserves an existing SecretRef when no flag or env override is provided", () => { + 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?.nextConfig.gateway?.auth?.token).toEqual(SAMPLE_SECRET_REF); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("preserves an existing SecretRef even when ambient OPENCLAW_GATEWAY_TOKEN is set", () => { + // A stale ambient env must not declassify a configured SecretRef. + process.env.OPENCLAW_GATEWAY_TOKEN = "stale-env-token"; + 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?.nextConfig.gateway?.auth?.token).toEqual(SAMPLE_SECRET_REF); + expect(randomToken).not.toHaveBeenCalled(); + }); + + 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 } }, + } as unknown as OpenClawConfig; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig, + opts: baseOpts, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + 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", () => { + const nextConfig = { + gateway: { auth: { mode: "token", token: SAMPLE_SECRET_REF } }, + } as unknown as OpenClawConfig; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig, + opts: { gatewayToken: "flag-token" } as OnboardOptions, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + expect(result?.nextConfig.gateway?.auth?.token).toBe("flag-token"); + expect(randomToken).not.toHaveBeenCalled(); + }); + + it("overrides an existing SecretRef when --gateway-token-ref-env is provided", () => { + const newRefId = "OPENCLAW_GATEWAY_TOKEN_NEW_REF"; + process.env[newRefId] = "resolved-new-ref-value"; + try { + const nextConfig = { + gateway: { auth: { mode: "token", token: SAMPLE_SECRET_REF } }, + } as unknown as OpenClawConfig; + + const result = applyNonInteractiveGatewayConfig({ + nextConfig, + opts: { gatewayTokenRefEnv: newRefId } as OnboardOptions, + runtime: createRuntime() as never, + defaultPort: 18789, + }); + + const newToken = result?.nextConfig.gateway?.auth?.token; + expect(newToken).toMatchObject({ source: "env", id: newRefId }); + expect(newToken).not.toEqual(SAMPLE_SECRET_REF); + expect(randomToken).not.toHaveBeenCalled(); + } finally { + delete process.env[newRefId]; + } + }); +}); diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index 05159f96ce5..bf60c5e4830 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { isValidEnvSecretRefId } from "../../../config/types.secrets.js"; +import { isValidEnvSecretRefId, resolveSecretInputRef } from "../../../config/types.secrets.js"; import type { RuntimeEnv } from "../../../runtime.js"; import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; @@ -18,7 +18,6 @@ export function applyNonInteractiveGatewayConfig(params: { authMode: string; tailscaleMode: string; tailscaleResetOnExit: boolean; - gatewayToken?: string; } | null { const { opts, runtime } = params; @@ -54,7 +53,17 @@ export function applyNonInteractiveGatewayConfig(params: { let nextConfig = params.nextConfig; const explicitGatewayToken = normalizeGatewayTokenInput(opts.gatewayToken); const envGatewayToken = normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN); - let gatewayToken = explicitGatewayToken || envGatewayToken || undefined; + const existingTokenInput = nextConfig.gateway?.auth?.token; + const existingTokenRef = resolveSecretInputRef({ + value: existingTokenInput, + defaults: nextConfig.secrets?.defaults, + }).ref; + const existingPlaintextToken = normalizeGatewayTokenInput(existingTokenInput); + // Resolution order on re-onboard: explicit --gateway-token > persisted + // plaintext > ambient OPENCLAW_GATEWAY_TOKEN > randomToken(). Ambient env + // must not rotate a token already written to disk — a stale shell or + // launchd env var otherwise breaks already-paired clients. + let gatewayToken = explicitGatewayToken || existingPlaintextToken || envGatewayToken || undefined; const gatewayTokenRefEnv = normalizeOptionalString(opts.gatewayTokenRefEnv ?? "") ?? ""; if (authMode === "token") { @@ -95,6 +104,22 @@ export function applyNonInteractiveGatewayConfig(params: { }, }, }; + } else if (!explicitGatewayToken && existingTokenRef) { + // Preserve an already-configured SecretRef on re-onboard. Without this + // branch, an ambient OPENCLAW_GATEWAY_TOKEN (or randomToken() fallback) + // would silently overwrite {source, provider, id} with a plaintext + // literal, de-secretref-ing the gateway. + nextConfig = { + ...nextConfig, + gateway: { + ...nextConfig.gateway, + auth: { + ...nextConfig.gateway?.auth, + mode: "token", + // token field intentionally preserved as the existing SecretRef. + }, + }, + }; } else { if (!gatewayToken) { gatewayToken = randomToken(); @@ -154,6 +179,5 @@ export function applyNonInteractiveGatewayConfig(params: { authMode, tailscaleMode, tailscaleResetOnExit, - gatewayToken, }; }