From 08431da5d57a4d6aa35964d6f80796c8855218e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 18:54:58 +0100 Subject: [PATCH] refactor(gateway): unify credential precedence across entrypoints --- CHANGELOG.md | 3 + docs/gateway/remote.md | 14 ++ src/agents/tools/gateway.test.ts | 91 +++++++++- src/agents/tools/gateway.ts | 80 +++++--- src/cli/daemon-cli/lifecycle-core.test.ts | 35 +++- src/cli/daemon-cli/lifecycle-core.ts | 10 +- src/commands/status.gateway-probe.ts | 26 +-- src/gateway/auth.test.ts | 18 ++ src/gateway/auth.ts | 13 +- src/gateway/client.test.ts | 23 +++ src/gateway/client.ts | 6 +- .../credential-precedence.parity.test.ts | 171 ++++++++++++++++++ src/gateway/credentials.test.ts | 75 +++++++- src/gateway/credentials.ts | 135 ++++++++++++-- src/gateway/probe-auth.ts | 32 +--- 15 files changed, 636 insertions(+), 96 deletions(-) create mode 100644 src/gateway/credential-precedence.parity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 521e28fcb10..f8a450fc169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index 6eedfc3b35d..52b6e095390 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -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. diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index db2cecfa710..5faeaba54d5 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -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, +})); 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" }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index d4db24ef4c3..c31b7751e10 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -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): 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; + urlOverride: string; +}): { url: string; target: GatewayOverrideTarget } { + const { cfg } = params; const port = resolveGatewayPort(cfg); - const allowed = new Set([ + const localAllowed = new Set([ `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; + 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>( diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 7d0214e7685..cf8ccfe3110 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -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", diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 94707a43e27..fe5c8e516fb 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -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 diff --git a/src/commands/status.gateway-probe.ts b/src/commands/status.gateway-probe.ts index cec628281cf..f7b7425f415 100644 --- a/src/commands/status.gateway-probe.ts +++ b/src/commands/status.gateway-probe.ts @@ -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): { 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, + }); } diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index b8376085ba1..07d90d2d134 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -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({ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 0c402678ea2..6315a899e76 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -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"]; diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 6388c3646e4..263933d16bb 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -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", () => { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 775dba77ff7..c95bbbcc36d 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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; diff --git a/src/gateway/credential-precedence.parity.test.ts b/src/gateway/credential-precedence.parity.test.ts new file mode 100644 index 00000000000..91656beff0d --- /dev/null +++ b/src/gateway/credential-precedence.parity.test.ts @@ -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(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(); + 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); + }); +}); diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 6c3e5f15935..52b61143dce 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -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 { 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", + }); + }); }); diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index 38a6f246ecd..ff974728360 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -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 { + 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 }; } diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index fe3271be690..d73f63ed899 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -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", + }); }