refactor: unify gateway credential planning

This commit is contained in:
Peter Steinberger
2026-03-11 01:37:20 +00:00
parent 3a39dc4e18
commit 5716e52417
6 changed files with 538 additions and 335 deletions

View File

@@ -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;
}

View File

@@ -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<OpenClawConfig["secrets"]>["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),
};
}

View File

@@ -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<OpenClawConfig["secrets"]>["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>): 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",
});
}

View File

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

View File

@@ -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: {

View File

@@ -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<GatewayAuthSurfacePath, GatewayAuthSurfaceState>;
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<string, unknown> | 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,
}),
};
}