mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(onboard): preserve existing gateway auth token during re-onboard (#67821)
Merged via squash.
Prepared head SHA: e602f8f4ab
Co-authored-by: BKF-Gitty <263413630+BKF-Gitty@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user