refactor(gateway): unify credential precedence across entrypoints

This commit is contained in:
Peter Steinberger
2026-02-22 18:54:58 +01:00
parent 98427453ba
commit 08431da5d5
15 changed files with 636 additions and 96 deletions

View File

@@ -120,6 +120,24 @@ describe("gateway auth", () => {
});
});
it("keeps gateway auth config values ahead of env overrides", () => {
expect(
resolveGatewayAuth({
authConfig: {
token: "config-token",
password: "config-password",
},
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
token: "config-token",
password: "config-password",
});
});
it("resolves explicit auth mode none from config", () => {
expect(
resolveGatewayAuth({

View File

@@ -11,6 +11,7 @@ import {
type AuthRateLimiter,
type RateLimitCheckResult,
} from "./auth-rate-limit.js";
import { resolveGatewayCredentialsFromValues } from "./credentials.js";
import {
isLocalishHost,
isLoopbackAddress,
@@ -242,8 +243,16 @@ export function resolveGatewayAuth(params: {
}
}
const env = params.env ?? process.env;
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
const resolvedCredentials = resolveGatewayCredentialsFromValues({
configToken: authConfig.token,
configPassword: authConfig.password,
env,
includeLegacyEnv: false,
tokenPrecedence: "config-first",
passwordPrecedence: "config-first",
});
const token = resolvedCredentials.token;
const password = resolvedCredentials.password;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];

View File

@@ -287,6 +287,29 @@ describe("GatewayClient close handling", () => {
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: signature invalid");
client.stop();
});
it("does not clear persisted device auth when explicit shared token is provided", () => {
const onClose = vi.fn();
const identity: DeviceIdentity = {
deviceId: "dev-5",
privateKeyPem: "private-key",
publicKeyPem: "public-key",
};
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
deviceIdentity: identity,
token: "shared-token",
onClose,
});
client.start();
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
expect(clearDevicePairingMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
client.stop();
});
});
describe("GatewayClient connect auth payload", () => {

View File

@@ -179,10 +179,14 @@ export class GatewayClient {
this.ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
this.ws = null;
// If closed due to device token mismatch, clear the stored token and pairing so next attempt can get a fresh one
// Clear persisted device auth state only when device-token auth was active.
// Shared token/password failures can return the same close reason but should
// not erase a valid cached device token.
if (
code === 1008 &&
reasonText.toLowerCase().includes("device token mismatch") &&
!this.opts.token &&
!this.opts.password &&
this.opts.deviceIdentity
) {
const deviceId = this.opts.deviceIdentity.deviceId;

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import { resolveGatewayProbeAuth as resolveStatusGatewayProbeAuth } from "../commands/status.gateway-probe.js";
import type { OpenClawConfig, loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "./auth.js";
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
import { resolveGatewayProbeAuth } from "./probe-auth.js";
type ExpectedCredentialSet = {
call: { token?: string; password?: string };
probe: { token?: string; password?: string };
status: { token?: string; password?: string };
auth: { token?: string; password?: string };
};
type TestCase = {
name: string;
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
expected: ExpectedCredentialSet;
};
function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
const keys = [
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"CLAWDBOT_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_PASSWORD",
] as const;
const previous = new Map<string, string | undefined>();
for (const key of keys) {
previous.set(key, process.env[key]);
const nextValue = env[key];
if (typeof nextValue === "string") {
process.env[key] = nextValue;
} else {
delete process.env[key];
}
}
try {
return fn();
} finally {
for (const key of keys) {
const value = previous.get(key);
if (typeof value === "string") {
process.env[key] = value;
} else {
delete process.env[key];
}
}
}
}
describe("gateway credential precedence parity", () => {
const cases: TestCase[] = [
{
name: "local mode: env overrides config for call/probe/status, auth remains config-first",
cfg: {
gateway: {
mode: "local",
auth: {
token: "config-token",
password: "config-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
expected: {
call: { token: "env-token", password: "env-password" },
probe: { token: "env-token", password: "env-password" },
status: { token: "env-token", password: "env-password" },
auth: { token: "config-token", password: "config-password" },
},
},
{
name: "remote mode with remote token configured",
cfg: {
gateway: {
mode: "remote",
remote: {
token: "remote-token",
password: "remote-password",
},
auth: {
token: "local-token",
password: "local-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
expected: {
call: { token: "remote-token", password: "env-password" },
probe: { token: "remote-token", password: "env-password" },
status: { token: "remote-token", password: "env-password" },
auth: { token: "local-token", password: "local-password" },
},
},
{
name: "remote mode without remote token keeps remote probe/status strict",
cfg: {
gateway: {
mode: "remote",
remote: {
password: "remote-password",
},
auth: {
token: "local-token",
password: "local-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
expected: {
call: { token: "env-token", password: "env-password" },
probe: { token: undefined, password: "env-password" },
status: { token: undefined, password: "env-password" },
auth: { token: "local-token", password: "local-password" },
},
},
{
name: "legacy env vars are ignored by probe/status/auth but still supported for call path",
cfg: {
gateway: {
mode: "local",
auth: {},
},
} as OpenClawConfig,
env: {
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
} as NodeJS.ProcessEnv,
expected: {
call: { token: "legacy-token", password: "legacy-password" },
probe: { token: undefined, password: undefined },
status: { token: undefined, password: undefined },
auth: { token: undefined, password: undefined },
},
},
];
it.each(cases)("$name", ({ cfg, env, expected }) => {
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
const call = resolveGatewayCredentialsFromConfig({
cfg,
env,
});
const probe = resolveGatewayProbeAuth({
cfg,
mode,
env,
});
const status = withGatewayAuthEnv(env, () => resolveStatusGatewayProbeAuth(cfg));
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
env,
});
expect(call).toEqual(expected.call);
expect(probe).toEqual(expected.probe);
expect(status).toEqual(expected.status);
expect({ token: auth.token, password: auth.password }).toEqual(expected.auth);
});
});

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
import {
resolveGatewayCredentialsFromConfig,
resolveGatewayCredentialsFromValues,
} from "./credentials.js";
function cfg(input: Partial<OpenClawConfig>): OpenClawConfig {
return input as OpenClawConfig;
@@ -77,7 +80,7 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
expect(resolved).toEqual({
token: "remote-token",
password: "remote-password",
password: "env-password",
});
});
@@ -121,4 +124,72 @@ describe("resolveGatewayCredentialsFromConfig", () => {
password: "env-password",
});
});
it("supports remote-only token fallback for strict remote override call sites", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "remote",
remote: { url: "wss://gateway.example" },
auth: { token: "local-token" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
} as NodeJS.ProcessEnv,
remoteTokenFallback: "remote-only",
});
expect(resolved.token).toBeUndefined();
});
it("can disable legacy CLAWDBOT env fallback", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
},
}),
env: {
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
});
expect(resolved).toEqual({ token: undefined, password: undefined });
});
});
describe("resolveGatewayCredentialsFromValues", () => {
it("supports config-first precedence for token/password", () => {
const resolved = resolveGatewayCredentialsFromValues({
configToken: "config-token",
configPassword: "config-password",
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
tokenPrecedence: "config-first",
passwordPrecedence: "config-first",
});
expect(resolved).toEqual({
token: "config-token",
password: "config-password",
});
});
it("uses env-first precedence by default", () => {
const resolved = resolveGatewayCredentialsFromValues({
configToken: "config-token",
configPassword: "config-password",
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
});
expect(resolved).toEqual({
token: "env-token",
password: "env-password",
});
});
});

View File

@@ -10,7 +10,12 @@ export type ResolvedGatewayCredentials = {
password?: string;
};
function trimToUndefined(value: unknown): string | undefined {
export type GatewayCredentialMode = "local" | "remote";
export type GatewayCredentialPrecedence = "env-first" | "config-first";
export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first";
export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only";
export function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
@@ -18,14 +23,88 @@ function trimToUndefined(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
function firstDefined(values: Array<string | undefined>): string | undefined {
for (const value of values) {
if (value) {
return value;
}
}
return undefined;
}
function readGatewayTokenEnv(
env: NodeJS.ProcessEnv,
includeLegacyEnv: boolean,
): string | undefined {
const primary = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
if (primary) {
return primary;
}
if (!includeLegacyEnv) {
return undefined;
}
return trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
}
function readGatewayPasswordEnv(
env: NodeJS.ProcessEnv,
includeLegacyEnv: boolean,
): string | undefined {
const primary = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
if (primary) {
return primary;
}
if (!includeLegacyEnv) {
return undefined;
}
return trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD);
}
export function resolveGatewayCredentialsFromValues(params: {
configToken?: string;
configPassword?: string;
env?: NodeJS.ProcessEnv;
includeLegacyEnv?: boolean;
tokenPrecedence?: GatewayCredentialPrecedence;
passwordPrecedence?: GatewayCredentialPrecedence;
}): ResolvedGatewayCredentials {
const env = params.env ?? process.env;
const includeLegacyEnv = params.includeLegacyEnv ?? true;
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
const configToken = trimToUndefined(params.configToken);
const configPassword = trimToUndefined(params.configPassword);
const tokenPrecedence = params.tokenPrecedence ?? "env-first";
const passwordPrecedence = params.passwordPrecedence ?? "env-first";
const token =
tokenPrecedence === "config-first"
? firstDefined([configToken, envToken])
: firstDefined([envToken, configToken]);
const password =
passwordPrecedence === "config-first"
? firstDefined([configPassword, envPassword])
: firstDefined([envPassword, configPassword]);
return { token, password };
}
export function resolveGatewayCredentialsFromConfig(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
explicitAuth?: ExplicitGatewayAuth;
urlOverride?: string;
remotePasswordPrecedence?: "remote-first" | "env-first";
modeOverride?: GatewayCredentialMode;
includeLegacyEnv?: boolean;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
remotePasswordFallback?: GatewayRemoteCredentialFallback;
}): ResolvedGatewayCredentials {
const env = params.env ?? process.env;
const includeLegacyEnv = params.includeLegacyEnv ?? true;
const explicitToken = trimToUndefined(params.explicitAuth?.token);
const explicitPassword = trimToUndefined(params.explicitAuth?.password);
if (explicitToken || explicitPassword) {
@@ -35,27 +114,49 @@ export function resolveGatewayCredentialsFromConfig(params: {
return {};
}
const isRemoteMode = params.cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? params.cfg.gateway?.remote : undefined;
const envToken =
trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
const envPassword =
trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ??
trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD);
const mode: GatewayCredentialMode =
params.modeOverride ?? (params.cfg.gateway?.mode === "remote" ? "remote" : "local");
const remote = mode === "remote" ? params.cfg.gateway?.remote : undefined;
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
const remoteToken = trimToUndefined(remote?.token);
const remotePassword = trimToUndefined(remote?.password);
const localToken = trimToUndefined(params.cfg.gateway?.auth?.token);
const localPassword = trimToUndefined(params.cfg.gateway?.auth?.password);
const token = isRemoteMode ? (remoteToken ?? envToken ?? localToken) : (envToken ?? localToken);
const passwordPrecedence = params.remotePasswordPrecedence ?? "remote-first";
const password = isRemoteMode
? passwordPrecedence === "env-first"
? (envPassword ?? remotePassword ?? localPassword)
: (remotePassword ?? envPassword ?? localPassword)
: (envPassword ?? localPassword);
const localTokenPrecedence = params.localTokenPrecedence ?? "env-first";
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
if (mode === "local") {
const localResolved = resolveGatewayCredentialsFromValues({
configToken: localToken,
configPassword: localPassword,
env,
includeLegacyEnv,
tokenPrecedence: localTokenPrecedence,
passwordPrecedence: localPasswordPrecedence,
});
return localResolved;
}
const remoteTokenFallback = params.remoteTokenFallback ?? "remote-env-local";
const remotePasswordFallback = params.remotePasswordFallback ?? "remote-env-local";
const remoteTokenPrecedence = params.remoteTokenPrecedence ?? "remote-first";
const remotePasswordPrecedence = params.remotePasswordPrecedence ?? "env-first";
const token =
remoteTokenFallback === "remote-only"
? remoteToken
: remoteTokenPrecedence === "env-first"
? firstDefined([envToken, remoteToken, localToken])
: firstDefined([remoteToken, envToken, localToken]);
const password =
remotePasswordFallback === "remote-only"
? remotePassword
: remotePasswordPrecedence === "env-first"
? firstDefined([envPassword, remotePassword, localPassword])
: firstDefined([remotePassword, envPassword, localPassword]);
return { token, password };
}

View File

@@ -1,32 +1,16 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
export function resolveGatewayProbeAuth(params: {
cfg: OpenClawConfig;
mode: "local" | "remote";
env?: NodeJS.ProcessEnv;
}): { token?: string; password?: string } {
const env = params.env ?? process.env;
const authToken = params.cfg.gateway?.auth?.token;
const authPassword = params.cfg.gateway?.auth?.password;
const remote = params.cfg.gateway?.remote;
const token =
params.mode === "remote"
? typeof remote?.token === "string" && remote.token.trim()
? remote.token.trim()
: undefined
: env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined);
const password =
env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
(params.mode === "remote"
? typeof remote?.password === "string" && remote.password.trim()
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim()
? authPassword.trim()
: undefined);
return { token, password };
return resolveGatewayCredentialsFromConfig({
cfg: params.cfg,
env: params.env,
modeOverride: params.mode,
includeLegacyEnv: false,
remoteTokenFallback: "remote-only",
});
}