diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b827ae6db..2e0274c5a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai - Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn. - Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant. - Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases. +- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. Thanks @tdjackey. ## 2026.3.8 diff --git a/src/cli/daemon-cli/gateway-token-drift.test.ts b/src/cli/daemon-cli/gateway-token-drift.test.ts index ff221b24e44..0b9d0cfb308 100644 --- a/src/cli/daemon-cli/gateway-token-drift.test.ts +++ b/src/cli/daemon-cli/gateway-token-drift.test.ts @@ -43,4 +43,29 @@ describe("resolveGatewayTokenForDriftCheck", () => { }), ).toThrow(/gateway\.auth\.token/i); }); + + it("does not fall back to gateway.remote token for unresolved local token refs", () => { + expect(() => + resolveGatewayTokenForDriftCheck({ + cfg: { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + remote: { + token: "remote-token", + }, + }, + } as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + }), + ).toThrow(/gateway\.auth\.token/i); + }); }); diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index f1e87fc4938..3f0ed6d531c 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> ->(); +const probeGateway = + vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> + >(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 5a6ea041c92..2ec45139d09 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -222,6 +222,58 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.auth.token"); }); + it("throws when unresolved local token SecretRef would otherwise fall back to remote token", () => { + expect(() => + resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + remote: { + token: "remote-token", + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).toThrow("gateway.auth.token"); + }); + + it("throws when unresolved local password SecretRef would otherwise fall back to remote password", () => { + expect(() => + resolveGatewayCredentialsFromConfig({ + cfg: { + gateway: { + mode: "local", + auth: { + mode: "password", + password: { source: "env", provider: "default", id: "MISSING_LOCAL_PASSWORD" }, + }, + remote: { + password: "remote-password", // pragma: allowlist secret + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).toThrow("gateway.auth.password"); + }); + it("ignores unresolved local password ref when local auth mode is none", () => { const resolved = resolveLocalModeWithUnresolvedPassword("none"); expect(resolved).toEqual({ diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index 0e9a7c1e07d..d5c3c6037a2 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import { containsEnvVarReference } from "../config/env-substitution.js"; -import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; export type ExplicitGatewayAuth = { token?: string; @@ -16,6 +16,13 @@ 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"; +type GatewaySecretDefaults = NonNullable["defaults"]; + +type GatewayConfiguredCredentialInput = { + configured: boolean; + value?: string; + refPath?: string; +}; const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE"; // pragma: allowlist secret @@ -85,6 +92,22 @@ function throwUnresolvedGatewaySecretInput(path: string): never { throw new GatewaySecretRefUnavailableError(path); } +function resolveConfiguredGatewayCredentialInput(params: { + value: unknown; + defaults?: GatewaySecretDefaults; + path: string; +}): GatewayConfiguredCredentialInput { + const ref = resolveSecretInputRef({ + value: params.value, + defaults: params.defaults, + }).ref; + return { + configured: hasConfiguredSecretInput(params.value, params.defaults), + value: ref ? undefined : trimToUndefined(params.value), + refPath: ref ? params.path : undefined, + }; +} + export function readGatewayTokenEnv( env: NodeJS.ProcessEnv = process.env, includeLegacyEnv = true, @@ -200,28 +223,34 @@ export function resolveGatewayCredentialsFromConfig(params: { const envToken = readGatewayTokenEnv(env, includeLegacyEnv); const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv); - const localTokenRef = resolveSecretInputRef({ + const localTokenInput = resolveConfiguredGatewayCredentialInput({ value: params.cfg.gateway?.auth?.token, defaults, - }).ref; - const localPasswordRef = resolveSecretInputRef({ + path: "gateway.auth.token", + }); + const localPasswordInput = resolveConfiguredGatewayCredentialInput({ value: params.cfg.gateway?.auth?.password, defaults, - }).ref; - const remoteTokenRef = resolveSecretInputRef({ + path: "gateway.auth.password", + }); + const remoteTokenInput = resolveConfiguredGatewayCredentialInput({ value: remote?.token, defaults, - }).ref; - const remotePasswordRef = resolveSecretInputRef({ + path: "gateway.remote.token", + }); + const remotePasswordInput = resolveConfiguredGatewayCredentialInput({ value: remote?.password, defaults, - }).ref; - const remoteToken = remoteTokenRef ? undefined : trimToUndefined(remote?.token); - const remotePassword = remotePasswordRef ? undefined : trimToUndefined(remote?.password); - const localToken = localTokenRef ? undefined : trimToUndefined(params.cfg.gateway?.auth?.token); - const localPassword = localPasswordRef - ? undefined - : trimToUndefined(params.cfg.gateway?.auth?.password); + path: "gateway.remote.password", + }); + const localTokenRef = localTokenInput.refPath; + const localPasswordRef = localPasswordInput.refPath; + const remoteTokenRef = remoteTokenInput.refPath; + const remotePasswordRef = remotePasswordInput.refPath; + const remoteToken = remoteTokenInput.value; + const remotePassword = remotePasswordInput.value; + const localToken = localTokenInput.value; + const localPassword = localPasswordInput.value; const localTokenPrecedence = params.localTokenPrecedence ?? @@ -232,8 +261,8 @@ export function resolveGatewayCredentialsFromConfig(params: { // In local mode, prefer gateway.auth.token, but also accept gateway.remote.token // as a fallback for cron commands and other local gateway clients. // This allows users in remote mode to use a single token for all operations. - const fallbackToken = localToken ?? remoteToken; - const fallbackPassword = localPassword ?? remotePassword; + const fallbackToken = localTokenInput.configured ? localToken : remoteToken; + const fallbackPassword = localPasswordInput.configured ? localPassword : remotePassword; const localResolved = resolveGatewayCredentialsFromValues({ configToken: fallbackToken, configPassword: fallbackPassword, diff --git a/src/gateway/probe-auth.test.ts b/src/gateway/probe-auth.test.ts index e31dd4856ad..7a6d639e10a 100644 --- a/src/gateway/probe-auth.test.ts +++ b/src/gateway/probe-auth.test.ts @@ -51,6 +51,34 @@ describe("resolveGatewayProbeAuthSafe", () => { expect(result.warning).toContain("unresolved"); }); + it("does not fall through to remote token when local token SecretRef is unresolved", () => { + const result = resolveGatewayProbeAuthSafe({ + cfg: { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + remote: { + token: "remote-token", + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as OpenClawConfig, + mode: "local", + env: {} as NodeJS.ProcessEnv, + }); + + expect(result.auth).toEqual({}); + expect(result.warning).toContain("gateway.auth.token"); + expect(result.warning).toContain("unresolved"); + }); + it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => { const result = resolveGatewayProbeAuthSafe({ cfg: {