From 5716e524171b3a6e5d3d0077612ba78d8faa3de6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 11 Mar 2026 01:37:20 +0000 Subject: [PATCH] refactor: unify gateway credential planning --- src/cli/daemon-cli/gateway-token-drift.ts | 12 +- src/gateway/credential-planner.ts | 220 ++++++++++ src/gateway/credentials.ts | 415 +++++++++--------- src/gateway/probe-auth.ts | 42 +- .../runtime-gateway-auth-surfaces.test.ts | 22 + src/secrets/runtime-gateway-auth-surfaces.ts | 162 +++---- 6 files changed, 538 insertions(+), 335 deletions(-) create mode 100644 src/gateway/credential-planner.ts diff --git a/src/cli/daemon-cli/gateway-token-drift.ts b/src/cli/daemon-cli/gateway-token-drift.ts index e382a7a91c3..a05ea975ca2 100644 --- a/src/cli/daemon-cli/gateway-token-drift.ts +++ b/src/cli/daemon-cli/gateway-token-drift.ts @@ -1,16 +1,10 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js"; +import { resolveGatewayDriftCheckCredentialsFromConfig } from "../../gateway/credentials.js"; export function resolveGatewayTokenForDriftCheck(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }) { - return resolveGatewayCredentialsFromConfig({ - cfg: params.cfg, - env: {} as NodeJS.ProcessEnv, - modeOverride: "local", - // Drift checks should compare the configured local token source against the - // persisted service token, not let exported shell env hide stale service state. - localTokenPrecedence: "config-first", - }).token; + void params.env; + return resolveGatewayDriftCheckCredentialsFromConfig({ cfg: params.cfg }).token; } diff --git a/src/gateway/credential-planner.ts b/src/gateway/credential-planner.ts new file mode 100644 index 00000000000..ba3a64e1642 --- /dev/null +++ b/src/gateway/credential-planner.ts @@ -0,0 +1,220 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { containsEnvVarReference } from "../config/env-substitution.js"; +import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; + +export type GatewayCredentialInputPath = + | "gateway.auth.token" + | "gateway.auth.password" + | "gateway.remote.token" + | "gateway.remote.password"; + +export type GatewayConfiguredCredentialInput = { + path: GatewayCredentialInputPath; + configured: boolean; + value?: string; + refPath?: GatewayCredentialInputPath; + hasSecretRef: boolean; +}; + +export type GatewayCredentialPlan = { + configuredMode: "local" | "remote"; + authMode?: string; + envToken?: string; + envPassword?: string; + localToken: GatewayConfiguredCredentialInput; + localPassword: GatewayConfiguredCredentialInput; + remoteToken: GatewayConfiguredCredentialInput; + remotePassword: GatewayConfiguredCredentialInput; + localTokenCanWin: boolean; + localPasswordCanWin: boolean; + localTokenSurfaceActive: boolean; + tokenCanWin: boolean; + passwordCanWin: boolean; + remoteMode: boolean; + remoteUrlConfigured: boolean; + tailscaleRemoteExposure: boolean; + remoteEnabled: boolean; + remoteConfiguredSurface: boolean; + remoteTokenFallbackActive: boolean; + remoteTokenActive: boolean; + remotePasswordFallbackActive: boolean; + remotePasswordActive: boolean; +}; + +type GatewaySecretDefaults = NonNullable["defaults"]; + +function readGatewayEnv( + env: NodeJS.ProcessEnv, + names: readonly string[], + includeLegacyEnv: boolean, +): string | undefined { + const keys = includeLegacyEnv ? names : names.slice(0, 1); + for (const name of keys) { + const value = trimToUndefined(env[name]); + if (value) { + return value; + } + } + return undefined; +} + +export function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Like trimToUndefined but also rejects unresolved env var placeholders (e.g. `${VAR}`). + * This prevents literal placeholder strings like `${OPENCLAW_GATEWAY_TOKEN}` from being + * accepted as valid credentials when the referenced env var is missing. + * Note: legitimate credential values containing literal `${UPPER_CASE}` patterns will + * also be rejected, but this is an extremely unlikely edge case. + */ +export function trimCredentialToUndefined(value: unknown): string | undefined { + const trimmed = trimToUndefined(value); + if (trimmed && containsEnvVarReference(trimmed)) { + return undefined; + } + return trimmed; +} + +export function readGatewayTokenEnv( + env: NodeJS.ProcessEnv = process.env, + includeLegacyEnv = true, +): string | undefined { + return readGatewayEnv( + env, + ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN"], + includeLegacyEnv, + ); +} + +export function readGatewayPasswordEnv( + env: NodeJS.ProcessEnv = process.env, + includeLegacyEnv = true, +): string | undefined { + return readGatewayEnv( + env, + ["OPENCLAW_GATEWAY_PASSWORD", "CLAWDBOT_GATEWAY_PASSWORD"], + includeLegacyEnv, + ); +} + +export function hasGatewayTokenEnvCandidate( + env: NodeJS.ProcessEnv = process.env, + includeLegacyEnv = true, +): boolean { + return Boolean(readGatewayTokenEnv(env, includeLegacyEnv)); +} + +export function hasGatewayPasswordEnvCandidate( + env: NodeJS.ProcessEnv = process.env, + includeLegacyEnv = true, +): boolean { + return Boolean(readGatewayPasswordEnv(env, includeLegacyEnv)); +} + +function resolveConfiguredGatewayCredentialInput(params: { + value: unknown; + defaults?: GatewaySecretDefaults; + path: GatewayCredentialInputPath; +}): GatewayConfiguredCredentialInput { + const ref = resolveSecretInputRef({ + value: params.value, + defaults: params.defaults, + }).ref; + return { + path: params.path, + configured: hasConfiguredSecretInput(params.value, params.defaults), + value: ref ? undefined : trimToUndefined(params.value), + refPath: ref ? params.path : undefined, + hasSecretRef: ref !== null, + }; +} + +export function createGatewayCredentialPlan(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + includeLegacyEnv?: boolean; + defaults?: GatewaySecretDefaults; +}): GatewayCredentialPlan { + const env = params.env ?? process.env; + const includeLegacyEnv = params.includeLegacyEnv ?? true; + const gateway = params.config.gateway; + const remote = gateway?.remote; + const defaults = params.defaults ?? params.config.secrets?.defaults; + const authMode = gateway?.auth?.mode; + const envToken = readGatewayTokenEnv(env, includeLegacyEnv); + const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv); + + const localToken = resolveConfiguredGatewayCredentialInput({ + value: gateway?.auth?.token, + defaults, + path: "gateway.auth.token", + }); + const localPassword = resolveConfiguredGatewayCredentialInput({ + value: gateway?.auth?.password, + defaults, + path: "gateway.auth.password", + }); + const remoteToken = resolveConfiguredGatewayCredentialInput({ + value: remote?.token, + defaults, + path: "gateway.remote.token", + }); + const remotePassword = resolveConfiguredGatewayCredentialInput({ + value: remote?.password, + defaults, + path: "gateway.remote.password", + }); + + const localTokenCanWin = + authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"; + const tokenCanWin = Boolean(envToken || localToken.configured || remoteToken.configured); + const passwordCanWin = + authMode === "password" || + (authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin); + const localTokenSurfaceActive = + localTokenCanWin && + !envToken && + (authMode === "token" || + (authMode === undefined && !(envPassword || localPassword.configured))); + + const remoteMode = gateway?.mode === "remote"; + const remoteUrlConfigured = Boolean(trimToUndefined(remote?.url)); + const tailscaleRemoteExposure = + gateway?.tailscale?.mode === "serve" || gateway?.tailscale?.mode === "funnel"; + const remoteEnabled = remote?.enabled !== false; + const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure; + const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localToken.configured; + const remotePasswordFallbackActive = !envPassword && !localPassword.configured && passwordCanWin; + + return { + configuredMode: gateway?.mode === "remote" ? "remote" : "local", + authMode, + envToken, + envPassword, + localToken, + localPassword, + remoteToken, + remotePassword, + localTokenCanWin, + localPasswordCanWin: passwordCanWin, + localTokenSurfaceActive, + tokenCanWin, + passwordCanWin, + remoteMode, + remoteUrlConfigured, + tailscaleRemoteExposure, + remoteEnabled, + remoteConfiguredSurface, + remoteTokenFallbackActive, + remoteTokenActive: remoteEnabled && (remoteConfiguredSurface || remoteTokenFallbackActive), + remotePasswordFallbackActive, + remotePasswordActive: + remoteEnabled && (remoteConfiguredSurface || remotePasswordFallbackActive), + }; +} diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index d5c3c6037a2..bc5bb385716 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -1,6 +1,20 @@ import type { OpenClawConfig } from "../config/config.js"; -import { containsEnvVarReference } from "../config/env-substitution.js"; -import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js"; +import { + createGatewayCredentialPlan, + type GatewayCredentialPlan, + readGatewayPasswordEnv, + readGatewayTokenEnv, + trimCredentialToUndefined, + trimToUndefined, +} from "./credential-planner.js"; +export { + hasGatewayPasswordEnvCandidate, + hasGatewayTokenEnvCandidate, + readGatewayPasswordEnv, + readGatewayTokenEnv, + trimCredentialToUndefined, + trimToUndefined, +} from "./credential-planner.js"; export type ExplicitGatewayAuth = { token?: string; @@ -16,13 +30,6 @@ 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 @@ -56,29 +63,6 @@ export function isGatewaySecretRefUnavailableError( return error.path === expectedPath; } -export function trimToUndefined(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -/** - * Like trimToUndefined but also rejects unresolved env var placeholders (e.g. `${VAR}`). - * This prevents literal placeholder strings like `${OPENCLAW_GATEWAY_TOKEN}` from being - * accepted as valid credentials when the referenced env var is missing. - * Note: legitimate credential values containing literal `${UPPER_CASE}` patterns will - * also be rejected, but this is an extremely unlikely edge case. - */ -export function trimCredentialToUndefined(value: unknown): string | undefined { - const trimmed = trimToUndefined(value); - if (trimmed && containsEnvVarReference(trimmed)) { - return undefined; - } - return trimmed; -} - function firstDefined(values: Array): string | undefined { for (const value of values) { if (value) { @@ -92,64 +76,6 @@ 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, -): string | undefined { - const primary = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN); - if (primary) { - return primary; - } - if (!includeLegacyEnv) { - return undefined; - } - return trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN); -} - -export function readGatewayPasswordEnv( - env: NodeJS.ProcessEnv = process.env, - includeLegacyEnv = true, -): 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 hasGatewayTokenEnvCandidate( - env: NodeJS.ProcessEnv = process.env, - includeLegacyEnv = true, -): boolean { - return Boolean(readGatewayTokenEnv(env, includeLegacyEnv)); -} - -export function hasGatewayPasswordEnvCandidate( - env: NodeJS.ProcessEnv = process.env, - includeLegacyEnv = true, -): boolean { - return Boolean(readGatewayPasswordEnv(env, includeLegacyEnv)); -} - export function resolveGatewayCredentialsFromValues(params: { configToken?: unknown; configPassword?: unknown; @@ -179,6 +105,151 @@ export function resolveGatewayCredentialsFromValues(params: { return { token, password }; } +function resolveLocalGatewayCredentials(params: { + plan: GatewayCredentialPlan; + env: NodeJS.ProcessEnv; + includeLegacyEnv: boolean; + localTokenPrecedence: GatewayCredentialPrecedence; + localPasswordPrecedence: GatewayCredentialPrecedence; +}): ResolvedGatewayCredentials { + const fallbackToken = params.plan.localToken.configured + ? params.plan.localToken.value + : params.plan.remoteToken.value; + const fallbackPassword = params.plan.localPassword.configured + ? params.plan.localPassword.value + : params.plan.remotePassword.value; + const localResolved = resolveGatewayCredentialsFromValues({ + configToken: fallbackToken, + configPassword: fallbackPassword, + env: params.env, + includeLegacyEnv: params.includeLegacyEnv, + tokenPrecedence: params.localTokenPrecedence, + passwordPrecedence: params.localPasswordPrecedence, + }); + const localPasswordCanWin = + params.plan.authMode === "password" || + (params.plan.authMode !== "token" && + params.plan.authMode !== "none" && + params.plan.authMode !== "trusted-proxy" && + !localResolved.token); + const localTokenCanWin = + params.plan.authMode === "token" || + (params.plan.authMode !== "password" && + params.plan.authMode !== "none" && + params.plan.authMode !== "trusted-proxy" && + !localResolved.password); + + if ( + params.plan.localToken.refPath && + params.localTokenPrecedence === "config-first" && + !params.plan.localToken.value && + Boolean(params.plan.envToken) && + localTokenCanWin + ) { + throwUnresolvedGatewaySecretInput(params.plan.localToken.refPath); + } + if ( + params.plan.localPassword.refPath && + params.localPasswordPrecedence === "config-first" && // pragma: allowlist secret + !params.plan.localPassword.value && + Boolean(params.plan.envPassword) && + localPasswordCanWin + ) { + throwUnresolvedGatewaySecretInput(params.plan.localPassword.refPath); + } + if ( + params.plan.localToken.refPath && + !localResolved.token && + !params.plan.envToken && + localTokenCanWin + ) { + throwUnresolvedGatewaySecretInput(params.plan.localToken.refPath); + } + if ( + params.plan.localPassword.refPath && + !localResolved.password && + !params.plan.envPassword && + localPasswordCanWin + ) { + throwUnresolvedGatewaySecretInput(params.plan.localPassword.refPath); + } + return localResolved; +} + +function resolveRemoteGatewayCredentials(params: { + plan: GatewayCredentialPlan; + remoteTokenPrecedence: GatewayRemoteCredentialPrecedence; + remotePasswordPrecedence: GatewayRemoteCredentialPrecedence; + remoteTokenFallback: GatewayRemoteCredentialFallback; + remotePasswordFallback: GatewayRemoteCredentialFallback; +}): ResolvedGatewayCredentials { + const token = + params.remoteTokenFallback === "remote-only" + ? params.plan.remoteToken.value + : params.remoteTokenPrecedence === "env-first" + ? firstDefined([ + params.plan.envToken, + params.plan.remoteToken.value, + params.plan.localToken.value, + ]) + : firstDefined([ + params.plan.remoteToken.value, + params.plan.envToken, + params.plan.localToken.value, + ]); + const password = + params.remotePasswordFallback === "remote-only" // pragma: allowlist secret + ? params.plan.remotePassword.value + : params.remotePasswordPrecedence === "env-first" // pragma: allowlist secret + ? firstDefined([ + params.plan.envPassword, + params.plan.remotePassword.value, + params.plan.localPassword.value, + ]) + : firstDefined([ + params.plan.remotePassword.value, + params.plan.envPassword, + params.plan.localPassword.value, + ]); + const localTokenFallbackEnabled = params.remoteTokenFallback !== "remote-only"; + const localTokenFallback = + params.remoteTokenFallback === "remote-only" ? undefined : params.plan.localToken.value; + const localPasswordFallback = + params.remotePasswordFallback === "remote-only" ? undefined : params.plan.localPassword.value; // pragma: allowlist secret + + if ( + params.plan.remoteToken.refPath && + !token && + !params.plan.envToken && + !localTokenFallback && + !password + ) { + throwUnresolvedGatewaySecretInput(params.plan.remoteToken.refPath); + } + if ( + params.plan.remotePassword.refPath && + !password && + !params.plan.envPassword && + !localPasswordFallback && + !token + ) { + throwUnresolvedGatewaySecretInput(params.plan.remotePassword.refPath); + } + if ( + params.plan.localToken.refPath && + localTokenFallbackEnabled && + !token && + !password && + !params.plan.envToken && + !params.plan.remoteToken.value && + params.plan.localTokenCanWin + ) { + throwUnresolvedGatewaySecretInput(params.plan.localToken.refPath); + } + + return { token, password }; +} + export function resolveGatewayCredentialsFromConfig(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -215,42 +286,12 @@ export function resolveGatewayCredentialsFromConfig(params: { }); } - const mode: GatewayCredentialMode = - params.modeOverride ?? (params.cfg.gateway?.mode === "remote" ? "remote" : "local"); - const remote = params.cfg.gateway?.remote; - const defaults = params.cfg.secrets?.defaults; - const authMode = params.cfg.gateway?.auth?.mode; - const envToken = readGatewayTokenEnv(env, includeLegacyEnv); - const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv); - - const localTokenInput = resolveConfiguredGatewayCredentialInput({ - value: params.cfg.gateway?.auth?.token, - defaults, - path: "gateway.auth.token", + const plan = createGatewayCredentialPlan({ + config: params.cfg, + env, + includeLegacyEnv, }); - const localPasswordInput = resolveConfiguredGatewayCredentialInput({ - value: params.cfg.gateway?.auth?.password, - defaults, - path: "gateway.auth.password", - }); - const remoteTokenInput = resolveConfiguredGatewayCredentialInput({ - value: remote?.token, - defaults, - path: "gateway.remote.token", - }); - const remotePasswordInput = resolveConfiguredGatewayCredentialInput({ - value: remote?.password, - defaults, - 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 mode: GatewayCredentialMode = params.modeOverride ?? plan.configuredMode; const localTokenPrecedence = params.localTokenPrecedence ?? @@ -258,56 +299,13 @@ export function resolveGatewayCredentialsFromConfig(params: { const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first"; if (mode === "local") { - // 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 = localTokenInput.configured ? localToken : remoteToken; - const fallbackPassword = localPasswordInput.configured ? localPassword : remotePassword; - const localResolved = resolveGatewayCredentialsFromValues({ - configToken: fallbackToken, - configPassword: fallbackPassword, + return resolveLocalGatewayCredentials({ + plan, env, includeLegacyEnv, - tokenPrecedence: localTokenPrecedence, - passwordPrecedence: localPasswordPrecedence, + localTokenPrecedence, + localPasswordPrecedence, }); - const localPasswordCanWin = - authMode === "password" || - (authMode !== "token" && - authMode !== "none" && - authMode !== "trusted-proxy" && - !localResolved.token); - const localTokenCanWin = - authMode === "token" || - (authMode !== "password" && - authMode !== "none" && - authMode !== "trusted-proxy" && - !localResolved.password); - if ( - localTokenRef && - localTokenPrecedence === "config-first" && - !localToken && - Boolean(envToken) && - localTokenCanWin - ) { - throwUnresolvedGatewaySecretInput("gateway.auth.token"); - } - if ( - localPasswordRef && - localPasswordPrecedence === "config-first" && // pragma: allowlist secret - !localPassword && - Boolean(envPassword) && - localPasswordCanWin - ) { - throwUnresolvedGatewaySecretInput("gateway.auth.password"); - } - if (localTokenRef && !localResolved.token && !envToken && localTokenCanWin) { - throwUnresolvedGatewaySecretInput("gateway.auth.token"); - } - if (localPasswordRef && !localResolved.password && !envPassword && localPasswordCanWin) { - throwUnresolvedGatewaySecretInput("gateway.auth.password"); - } - return localResolved; } const remoteTokenFallback = params.remoteTokenFallback ?? "remote-env-local"; @@ -315,43 +313,38 @@ export function resolveGatewayCredentialsFromConfig(params: { 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" // pragma: allowlist secret - ? remotePassword - : remotePasswordPrecedence === "env-first" // pragma: allowlist secret - ? firstDefined([envPassword, remotePassword, localPassword]) - : firstDefined([remotePassword, envPassword, localPassword]); - - const localTokenCanWin = - authMode === "token" || - (authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"); - const localTokenFallbackEnabled = remoteTokenFallback !== "remote-only"; - const localTokenFallback = remoteTokenFallback === "remote-only" ? undefined : localToken; - const localPasswordFallback = - remotePasswordFallback === "remote-only" ? undefined : localPassword; // pragma: allowlist secret - if (remoteTokenRef && !token && !envToken && !localTokenFallback && !password) { - throwUnresolvedGatewaySecretInput("gateway.remote.token"); - } - if (remotePasswordRef && !password && !envPassword && !localPasswordFallback && !token) { - throwUnresolvedGatewaySecretInput("gateway.remote.password"); - } - if ( - localTokenRef && - localTokenFallbackEnabled && - !token && - !password && - !envToken && - !remoteToken && - localTokenCanWin - ) { - throwUnresolvedGatewaySecretInput("gateway.auth.token"); - } - - return { token, password }; + return resolveRemoteGatewayCredentials({ + plan, + remoteTokenPrecedence, + remotePasswordPrecedence, + remoteTokenFallback, + remotePasswordFallback, + }); +} + +export function resolveGatewayProbeCredentialsFromConfig(params: { + cfg: OpenClawConfig; + mode: GatewayCredentialMode; + env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; +}): ResolvedGatewayCredentials { + return resolveGatewayCredentialsFromConfig({ + cfg: params.cfg, + env: params.env, + explicitAuth: params.explicitAuth, + modeOverride: params.mode, + includeLegacyEnv: false, + remoteTokenFallback: "remote-only", + }); +} + +export function resolveGatewayDriftCheckCredentialsFromConfig(params: { + cfg: OpenClawConfig; +}): ResolvedGatewayCredentials { + return resolveGatewayCredentialsFromConfig({ + cfg: params.cfg, + env: {} as NodeJS.ProcessEnv, + modeOverride: "local", + localTokenPrecedence: "config-first", + }); } diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index a651e5afa60..64980be601e 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -3,21 +3,34 @@ import { resolveGatewayCredentialsWithSecretInputs } from "./call.js"; import { type ExplicitGatewayAuth, isGatewaySecretRefUnavailableError, - resolveGatewayCredentialsFromConfig, + resolveGatewayProbeCredentialsFromConfig, } from "./credentials.js"; +function buildGatewayProbeCredentialPolicy(params: { + cfg: OpenClawConfig; + mode: "local" | "remote"; + env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; +}) { + return { + config: params.cfg, + cfg: params.cfg, + env: params.env, + explicitAuth: params.explicitAuth, + modeOverride: params.mode, + mode: params.mode, + includeLegacyEnv: false, + remoteTokenFallback: "remote-only" as const, + }; +} + export function resolveGatewayProbeAuth(params: { cfg: OpenClawConfig; mode: "local" | "remote"; env?: NodeJS.ProcessEnv; }): { token?: string; password?: string } { - return resolveGatewayCredentialsFromConfig({ - cfg: params.cfg, - env: params.env, - modeOverride: params.mode, - includeLegacyEnv: false, - remoteTokenFallback: "remote-only", - }); + const policy = buildGatewayProbeCredentialPolicy(params); + return resolveGatewayProbeCredentialsFromConfig(policy); } export async function resolveGatewayProbeAuthWithSecretInputs(params: { @@ -26,13 +39,14 @@ export async function resolveGatewayProbeAuthWithSecretInputs(params: { env?: NodeJS.ProcessEnv; explicitAuth?: ExplicitGatewayAuth; }): Promise<{ token?: string; password?: string }> { + const policy = buildGatewayProbeCredentialPolicy(params); return await resolveGatewayCredentialsWithSecretInputs({ - config: params.cfg, - env: params.env, - explicitAuth: params.explicitAuth, - modeOverride: params.mode, - includeLegacyEnv: false, - remoteTokenFallback: "remote-only", + config: policy.config, + env: policy.env, + explicitAuth: policy.explicitAuth, + modeOverride: policy.modeOverride, + includeLegacyEnv: policy.includeLegacyEnv, + remoteTokenFallback: policy.remoteTokenFallback, }); } diff --git a/src/secrets/runtime-gateway-auth-surfaces.test.ts b/src/secrets/runtime-gateway-auth-surfaces.test.ts index f84728b3041..bc461f23813 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.test.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.test.ts @@ -144,6 +144,28 @@ describe("evaluateGatewayAuthSurfaceStates", () => { }); }); + it("marks gateway.remote.token inactive when local token SecretRef is configured", () => { + const states = evaluate({ + gateway: { + mode: "local", + auth: { + mode: "token", + token: envRef("GW_AUTH_TOKEN"), + }, + remote: { + enabled: true, + token: envRef("GW_REMOTE_TOKEN"), + }, + }, + } as OpenClawConfig); + + expect(states["gateway.remote.token"]).toMatchObject({ + hasSecretRef: true, + active: false, + reason: "gateway.auth.token is configured.", + }); + }); + it("marks gateway.remote.password active when remote url is configured", () => { const states = evaluate({ gateway: { diff --git a/src/secrets/runtime-gateway-auth-surfaces.ts b/src/secrets/runtime-gateway-auth-surfaces.ts index 7fa73096730..c5abc61b2a8 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.ts @@ -1,14 +1,8 @@ import type { OpenClawConfig } from "../config/config.js"; -import { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js"; +import { createGatewayCredentialPlan } from "../gateway/credential-planner.js"; import type { SecretDefaults } from "./runtime-shared.js"; import { isRecord } from "./shared.js"; -const GATEWAY_TOKEN_ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN"] as const; -const GATEWAY_PASSWORD_ENV_KEYS = [ - "OPENCLAW_GATEWAY_PASSWORD", - "CLAWDBOT_GATEWAY_PASSWORD", -] as const; - export const GATEWAY_AUTH_SURFACE_PATHS = [ "gateway.auth.token", "gateway.auth.password", @@ -27,20 +21,6 @@ export type GatewayAuthSurfaceState = { export type GatewayAuthSurfaceStateMap = Record; -function readNonEmptyEnv(env: NodeJS.ProcessEnv, names: readonly string[]): string | undefined { - for (const name of names) { - const raw = env[name]; - if (typeof raw !== "string") { - continue; - } - const trimmed = raw.trim(); - if (trimmed.length > 0) { - return trimmed; - } - } - return undefined; -} - function formatAuthMode(mode: string | undefined): string { return mode ?? "unset"; } @@ -82,7 +62,6 @@ export function evaluateGatewayAuthSurfaceStates(params: { env: NodeJS.ProcessEnv; defaults?: SecretDefaults; }): GatewayAuthSurfaceStateMap { - const defaults = params.defaults ?? params.config.secrets?.defaults; const gateway = params.config.gateway as Record | undefined; if (!isRecord(gateway)) { return { @@ -114,65 +93,36 @@ export function evaluateGatewayAuthSurfaceStates(params: { } const auth = isRecord(gateway?.auth) ? gateway.auth : undefined; const remote = isRecord(gateway?.remote) ? gateway.remote : undefined; - const authMode = auth && typeof auth.mode === "string" ? auth.mode : undefined; - - const hasAuthTokenRef = coerceSecretRef(auth?.token, defaults) !== null; - const hasAuthPasswordRef = coerceSecretRef(auth?.password, defaults) !== null; - const hasRemoteTokenRef = coerceSecretRef(remote?.token, defaults) !== null; - const hasRemotePasswordRef = coerceSecretRef(remote?.password, defaults) !== null; - - const envToken = readNonEmptyEnv(params.env, GATEWAY_TOKEN_ENV_KEYS); - const envPassword = readNonEmptyEnv(params.env, GATEWAY_PASSWORD_ENV_KEYS); - const localTokenConfigured = hasConfiguredSecretInput(auth?.token, defaults); - const localPasswordConfigured = hasConfiguredSecretInput(auth?.password, defaults); - const remoteTokenConfigured = hasConfiguredSecretInput(remote?.token, defaults); - const passwordSourceConfigured = Boolean(envPassword || localPasswordConfigured); - - const localTokenCanWin = - authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy"; - const localTokenSurfaceActive = - localTokenCanWin && - !envToken && - (authMode === "token" || (authMode === undefined && !passwordSourceConfigured)); - const tokenCanWin = Boolean(envToken || localTokenConfigured || remoteTokenConfigured); - const passwordCanWin = - authMode === "password" || - (authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin); - - const remoteMode = gateway?.mode === "remote"; - const remoteUrlConfigured = typeof remote?.url === "string" && remote.url.trim().length > 0; - const tailscale = - isRecord(gateway?.tailscale) && typeof gateway.tailscale.mode === "string" - ? gateway.tailscale - : undefined; - const tailscaleRemoteExposure = tailscale?.mode === "serve" || tailscale?.mode === "funnel"; - const remoteEnabled = remote?.enabled !== false; - const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure; - const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localTokenConfigured; - const remoteTokenActive = remoteEnabled && (remoteConfiguredSurface || remoteTokenFallbackActive); - const remotePasswordFallbackActive = !envPassword && !localPasswordConfigured && passwordCanWin; - const remotePasswordActive = - remoteEnabled && (remoteConfiguredSurface || remotePasswordFallbackActive); + const plan = createGatewayCredentialPlan({ + config: params.config, + env: params.env, + includeLegacyEnv: true, + defaults: params.defaults, + }); const authPasswordReason = (() => { if (!auth) { return "gateway.auth is not configured."; } - if (passwordCanWin) { - return authMode === "password" + if (plan.passwordCanWin) { + return plan.authMode === "password" ? 'gateway.auth.mode is "password".' : "no token source can win, so password auth can win."; } - if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") { - return `gateway.auth.mode is "${authMode}".`; + if ( + plan.authMode === "token" || + plan.authMode === "none" || + plan.authMode === "trusted-proxy" + ) { + return `gateway.auth.mode is "${plan.authMode}".`; } - if (envToken) { + if (plan.envToken) { return "gateway token env var is configured."; } - if (localTokenConfigured) { + if (plan.localToken.configured) { return "gateway.auth.token is configured."; } - if (remoteTokenConfigured) { + if (plan.remoteToken.configured) { return "gateway.remote.token is configured."; } return "token auth can win."; @@ -182,50 +132,56 @@ export function evaluateGatewayAuthSurfaceStates(params: { if (!auth) { return "gateway.auth is not configured."; } - if (authMode === "token") { - return envToken ? "gateway token env var is configured." : 'gateway.auth.mode is "token".'; + if (plan.authMode === "token") { + return plan.envToken + ? "gateway token env var is configured." + : 'gateway.auth.mode is "token".'; } - if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") { - return `gateway.auth.mode is "${authMode}".`; + if ( + plan.authMode === "password" || + plan.authMode === "none" || + plan.authMode === "trusted-proxy" + ) { + return `gateway.auth.mode is "${plan.authMode}".`; } - if (envToken) { + if (plan.envToken) { return "gateway token env var is configured."; } - if (envPassword) { + if (plan.envPassword) { return "gateway password env var is configured."; } - if (localPasswordConfigured) { + if (plan.localPassword.configured) { return "gateway.auth.password is configured."; } return "token auth can win (mode is unset and no password source is configured)."; })(); const remoteSurfaceReason = describeRemoteConfiguredSurface({ - remoteMode, - remoteUrlConfigured, - tailscaleRemoteExposure, + remoteMode: plan.remoteMode, + remoteUrlConfigured: plan.remoteUrlConfigured, + tailscaleRemoteExposure: plan.tailscaleRemoteExposure, }); const remoteTokenReason = (() => { if (!remote) { return "gateway.remote is not configured."; } - if (!remoteEnabled) { + if (!plan.remoteEnabled) { return "gateway.remote.enabled is false."; } - if (remoteConfiguredSurface) { + if (plan.remoteConfiguredSurface) { return `remote surface is active: ${remoteSurfaceReason}.`; } - if (remoteTokenFallbackActive) { + if (plan.remoteTokenFallbackActive) { return "local token auth can win and no env/auth token is configured."; } - if (!localTokenCanWin) { - return `token auth cannot win with gateway.auth.mode="${formatAuthMode(authMode)}".`; + if (!plan.localTokenCanWin) { + return `token auth cannot win with gateway.auth.mode="${formatAuthMode(plan.authMode)}".`; } - if (envToken) { + if (plan.envToken) { return "gateway token env var is configured."; } - if (localTokenConfigured) { + if (plan.localToken.configured) { return "gateway.auth.token is configured."; } return "remote token fallback is not active."; @@ -235,25 +191,29 @@ export function evaluateGatewayAuthSurfaceStates(params: { if (!remote) { return "gateway.remote is not configured."; } - if (!remoteEnabled) { + if (!plan.remoteEnabled) { return "gateway.remote.enabled is false."; } - if (remoteConfiguredSurface) { + if (plan.remoteConfiguredSurface) { return `remote surface is active: ${remoteSurfaceReason}.`; } - if (remotePasswordFallbackActive) { + if (plan.remotePasswordFallbackActive) { return "password auth can win and no env/auth password is configured."; } - if (!passwordCanWin) { - if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") { - return `password auth cannot win with gateway.auth.mode="${authMode}".`; + if (!plan.passwordCanWin) { + if ( + plan.authMode === "token" || + plan.authMode === "none" || + plan.authMode === "trusted-proxy" + ) { + return `password auth cannot win with gateway.auth.mode="${plan.authMode}".`; } return "a token source can win, so password auth cannot win."; } - if (envPassword) { + if (plan.envPassword) { return "gateway password env var is configured."; } - if (localPasswordConfigured) { + if (plan.localPassword.configured) { return "gateway.auth.password is configured."; } return "remote password fallback is not active."; @@ -262,27 +222,27 @@ export function evaluateGatewayAuthSurfaceStates(params: { return { "gateway.auth.token": createState({ path: "gateway.auth.token", - active: localTokenSurfaceActive, + active: plan.localTokenSurfaceActive, reason: authTokenReason, - hasSecretRef: hasAuthTokenRef, + hasSecretRef: plan.localToken.hasSecretRef, }), "gateway.auth.password": createState({ path: "gateway.auth.password", - active: passwordCanWin, + active: plan.passwordCanWin, reason: authPasswordReason, - hasSecretRef: hasAuthPasswordRef, + hasSecretRef: plan.localPassword.hasSecretRef, }), "gateway.remote.token": createState({ path: "gateway.remote.token", - active: remoteTokenActive, + active: plan.remoteTokenActive, reason: remoteTokenReason, - hasSecretRef: hasRemoteTokenRef, + hasSecretRef: plan.remoteToken.hasSecretRef, }), "gateway.remote.password": createState({ path: "gateway.remote.password", - active: remotePasswordActive, + active: plan.remotePasswordActive, reason: remotePasswordReason, - hasSecretRef: hasRemotePasswordRef, + hasSecretRef: plan.remotePassword.hasSecretRef, }), }; }