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

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
- Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit `auth.deviceToken` support in connect frames and tests.
- Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints.
- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
- Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc.
@@ -64,6 +65,8 @@ Docs: https://docs.openclaw.ai
- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen.
- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation.
- Gateway/Tools: when agent tools pass an allowlisted `gatewayUrl` override, resolve local override tokens from env/config fallback but keep remote overrides strict to `gateway.remote.token`, preventing local token leakage to remote targets.
- Gateway/Client: keep cached device-auth tokens on `device token mismatch` closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures.
- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`).
- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -101,6 +101,20 @@ You can persist a remote target so CLI commands use it by default:
When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first.
## Credential precedence
Gateway call/probe credential resolution now follows one shared contract:
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win.
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password`
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
- Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only.
## Chat UI over SSH
WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects directly to the Gateway WebSocket.

View File

@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
const callGatewayMock = vi.fn();
const configState = vi.hoisted(() => ({
value: {} as Record<string, unknown>,
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => ({}),
loadConfig: () => configState.value,
resolveGatewayPort: () => 18789,
}));
vi.mock("../../gateway/call.js", () => ({
@@ -11,8 +14,29 @@ vi.mock("../../gateway/call.js", () => ({
}));
describe("gateway tool defaults", () => {
const envSnapshot = {
openclaw: process.env.OPENCLAW_GATEWAY_TOKEN,
clawdbot: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
beforeEach(() => {
callGatewayMock.mockClear();
configState.value = {};
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
});
afterAll(() => {
if (envSnapshot.openclaw === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = envSnapshot.openclaw;
}
if (envSnapshot.clawdbot === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = envSnapshot.clawdbot;
}
});
it("leaves url undefined so callGateway can use config", () => {
@@ -37,6 +61,69 @@ describe("gateway tool defaults", () => {
);
});
it("uses OPENCLAW_GATEWAY_TOKEN for allowlisted local overrides", () => {
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" });
expect(opts.url).toBe("ws://127.0.0.1:18789");
expect(opts.token).toBe("env-token");
});
it("falls back to config gateway.auth.token when env is unset for local overrides", () => {
configState.value = {
gateway: {
auth: { token: "config-token" },
},
};
const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" });
expect(opts.token).toBe("config-token");
});
it("uses gateway.remote.token for allowlisted remote overrides", () => {
configState.value = {
gateway: {
remote: {
url: "wss://gateway.example",
token: "remote-token",
},
},
};
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
expect(opts.url).toBe("wss://gateway.example");
expect(opts.token).toBe("remote-token");
});
it("does not leak local env/config tokens to remote overrides", () => {
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
process.env.CLAWDBOT_GATEWAY_TOKEN = "legacy-env-token";
configState.value = {
gateway: {
auth: { token: "local-config-token" },
remote: {
url: "wss://gateway.example",
},
},
};
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
expect(opts.token).toBeUndefined();
});
it("explicit gatewayToken overrides fallback token resolution", () => {
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
configState.value = {
gateway: {
remote: {
url: "wss://gateway.example",
token: "remote-token",
},
},
};
const opts = resolveGatewayOptions({
gatewayUrl: "wss://gateway.example",
gatewayToken: "explicit-token",
});
expect(opts.token).toBe("explicit-token");
});
it("uses least-privilege write scope for write methods", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
await callGatewayTool("wake", {}, { mode: "now", text: "hi" });

View File

@@ -1,5 +1,6 @@
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js";
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { readStringParam } from "./common.js";
@@ -12,6 +13,8 @@ export type GatewayCallOptions = {
timeoutMs?: number;
};
type GatewayOverrideTarget = "local" | "remote";
export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
return {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
@@ -50,10 +53,13 @@ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: strin
return { origin, key };
}
function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string {
const cfg = loadConfig();
function validateGatewayUrlOverrideForAgentTools(params: {
cfg: ReturnType<typeof loadConfig>;
urlOverride: string;
}): { url: string; target: GatewayOverrideTarget } {
const { cfg } = params;
const port = resolveGatewayPort(cfg);
const allowed = new Set<string>([
const localAllowed = new Set<string>([
`ws://127.0.0.1:${port}`,
`wss://127.0.0.1:${port}`,
`ws://localhost:${port}`,
@@ -62,45 +68,73 @@ function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string {
`wss://[::1]:${port}`,
]);
let remoteKey: string | undefined;
const remoteUrl =
typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : "";
if (remoteUrl) {
try {
const remote = canonicalizeToolGatewayWsUrl(remoteUrl);
allowed.add(remote.key);
remoteKey = remote.key;
} catch {
// ignore: misconfigured remote url; tools should fall back to default resolution.
}
}
const parsed = canonicalizeToolGatewayWsUrl(urlOverride);
if (!allowed.has(parsed.key)) {
throw new Error(
[
"gatewayUrl override rejected.",
`Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`,
"Or: configure gateway.remote.url and omit gatewayUrl to use the configured remote gateway.",
].join(" "),
);
const parsed = canonicalizeToolGatewayWsUrl(params.urlOverride);
if (localAllowed.has(parsed.key)) {
return { url: parsed.origin, target: "local" };
}
return parsed.origin;
if (remoteKey && parsed.key === remoteKey) {
return { url: parsed.origin, target: "remote" };
}
throw new Error(
[
"gatewayUrl override rejected.",
`Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`,
"Or: configure gateway.remote.url and omit gatewayUrl to use the configured remote gateway.",
].join(" "),
);
}
function resolveGatewayOverrideToken(params: {
cfg: ReturnType<typeof loadConfig>;
target: GatewayOverrideTarget;
explicitToken?: string;
}): string | undefined {
if (params.explicitToken) {
return params.explicitToken;
}
return resolveGatewayCredentialsFromConfig({
cfg: params.cfg,
env: process.env,
modeOverride: params.target,
remoteTokenFallback: params.target === "remote" ? "remote-only" : "remote-env-local",
remotePasswordFallback: params.target === "remote" ? "remote-only" : "remote-env-local",
}).token;
}
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
// Prefer an explicit override; otherwise let callGateway choose based on config.
const url =
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
? validateGatewayUrlOverrideForAgentTools(opts.gatewayUrl)
: undefined;
const token =
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
? opts.gatewayToken.trim()
const cfg = loadConfig();
const validatedOverride =
trimToUndefined(opts?.gatewayUrl) !== undefined
? validateGatewayUrlOverrideForAgentTools({
cfg,
urlOverride: String(opts?.gatewayUrl),
})
: undefined;
const explicitToken = trimToUndefined(opts?.gatewayToken);
const token = validatedOverride
? resolveGatewayOverrideToken({
cfg,
target: validatedOverride.target,
explicitToken,
})
: explicitToken;
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: 30_000;
return { url, token, timeoutMs };
return { url: validatedOverride?.url, token, timeoutMs };
}
export async function callGatewayTool<T = Record<string, unknown>>(

View File

@@ -47,7 +47,14 @@ describe("runServiceRestart token drift", () => {
beforeEach(() => {
runtimeLogs.length = 0;
loadConfig.mockClear();
loadConfig.mockReset();
loadConfig.mockReturnValue({
gateway: {
auth: {
token: "config-token",
},
},
});
service.isLoaded.mockClear();
service.readCommand.mockClear();
service.restart.mockClear();
@@ -76,6 +83,32 @@ describe("runServiceRestart token drift", () => {
expect(payload.warnings?.[0]).toContain("gateway install --force");
});
it("uses env-first token precedence when checking drift", async () => {
loadConfig.mockReturnValue({
gateway: {
auth: {
token: "config-token",
},
},
});
service.readCommand.mockResolvedValue({
environment: { OPENCLAW_GATEWAY_TOKEN: "env-token" },
});
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-token");
await runServiceRestart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [],
opts: { json: true },
checkTokenDrift: true,
});
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
expect(payload.warnings).toBeUndefined();
});
it("skips drift warning when disabled", async () => {
await runServiceRestart({
serviceNoun: "Node",

View File

@@ -5,6 +5,7 @@ import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
import { isWSL } from "../../infra/wsl.js";
import { defaultRuntime } from "../../runtime.js";
import {
@@ -280,10 +281,11 @@ export async function runServiceRestart(params: {
const command = await params.service.readCommand(process.env);
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
const cfg = loadConfig();
const configToken =
cfg.gateway?.auth?.token ||
process.env.OPENCLAW_GATEWAY_TOKEN ||
process.env.CLAWDBOT_GATEWAY_TOKEN;
const configToken = resolveGatewayCredentialsFromConfig({
cfg,
env: process.env,
modeOverride: "local",
}).token;
const driftIssue = checkTokenDrift({ serviceToken, configToken });
if (driftIssue) {
const warning = driftIssue.detail

View File

@@ -1,28 +1,14 @@
import type { loadConfig } from "../config/config.js";
import { resolveGatewayProbeAuth as resolveGatewayProbeAuthByMode } from "../gateway/probe-auth.js";
export { pickGatewaySelfPresence } from "./gateway-presence.js";
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
token?: string;
password?: string;
} {
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const authToken = cfg.gateway?.auth?.token;
const authPassword = cfg.gateway?.auth?.password;
const token = isRemoteMode
? typeof remote?.token === "string" && remote.token.trim().length > 0
? remote.token.trim()
: undefined
: process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim().length > 0 ? authToken.trim() : undefined);
const password =
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
(isRemoteMode
? typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim().length > 0
? authPassword.trim()
: undefined);
return { token, password };
return resolveGatewayProbeAuthByMode({
cfg,
mode: cfg.gateway?.mode === "remote" ? "remote" : "local",
env: process.env,
});
}

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