Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)

This commit is contained in:
Josh Avant
2026-03-05 12:53:56 -06:00
committed by GitHub
parent bc66a8fa81
commit 72cf9253fc
112 changed files with 5750 additions and 465 deletions

View File

@@ -147,6 +147,181 @@ describe("pairing setup code", () => {
expect(resolved.payload.token).toBe("tok_123");
});
it("resolves gateway.auth.token SecretRef for pairing payload", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
GW_TOKEN: "resolved-token",
},
},
);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("token");
expect(resolved.payload.token).toBe("resolved-token");
});
it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => {
await expect(
resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {},
},
),
).rejects.toThrow(/MISSING_GW_TOKEN/i);
});
it("uses password env in inferred mode without resolving token SecretRef", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
OPENCLAW_GATEWAY_PASSWORD: "password-from-env",
},
},
);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("password");
expect(resolved.payload.password).toBe("password-from-env");
});
it("does not treat env-template token as plaintext in inferred mode", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: "${MISSING_GW_TOKEN}",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
OPENCLAW_GATEWAY_PASSWORD: "password-from-env",
},
},
);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("password");
expect(resolved.payload.token).toBeUndefined();
expect(resolved.payload.password).toBe("password-from-env");
});
it("requires explicit auth mode when token and password are both configured", async () => {
await expect(
resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "GW_TOKEN" },
password: { source: "env", provider: "default", id: "GW_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
GW_TOKEN: "resolved-token",
GW_PASSWORD: "resolved-password",
},
},
),
).rejects.toThrow(/gateway\.auth\.mode is unset/i);
});
it("errors when token and password SecretRefs are both configured with inferred mode", async () => {
await expect(
resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
password: { source: "env", provider: "default", id: "GW_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
GW_PASSWORD: "resolved-password",
},
},
),
).rejects.toThrow(/gateway\.auth\.mode is unset/i);
});
it("honors env token override", async () => {
const resolved = await resolvePairingSetupFromConfig(
{

View File

@@ -1,7 +1,12 @@
import os from "node:os";
import { resolveGatewayPort } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
@@ -152,14 +157,23 @@ function pickTailnetIPv4(
function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthResult {
const mode = cfg.gateway?.auth?.mode;
const defaults = cfg.secrets?.defaults;
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults,
}).ref;
const passwordRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.password,
defaults,
}).ref;
const token =
env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
cfg.gateway?.auth?.token?.trim();
(tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token));
const password =
env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
normalizeSecretInputString(cfg.gateway?.auth?.password);
(passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password));
if (mode === "password") {
if (!password) {
@@ -182,6 +196,56 @@ function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthRe
return { error: "Gateway auth is not configured (no token or password)." };
}
async function resolveGatewayTokenSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<OpenClawConfig> {
const authToken = cfg.gateway?.auth?.token;
const { ref } = resolveSecretInputRef({
value: authToken,
defaults: cfg.secrets?.defaults,
});
if (!ref) {
return cfg;
}
const hasTokenEnvCandidate = Boolean(
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim(),
);
if (hasTokenEnvCandidate) {
return cfg;
}
const mode = cfg.gateway?.auth?.mode;
if (mode === "password" || mode === "none" || mode === "trusted-proxy") {
return cfg;
}
if (mode !== "token") {
const hasPasswordEnvCandidate = Boolean(
env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim(),
);
if (hasPasswordEnvCandidate) {
return cfg;
}
}
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
token: value.trim(),
},
},
};
}
async function resolveGatewayPasswordSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
@@ -207,7 +271,7 @@ async function resolveGatewayPasswordSecretRef(
if (mode !== "password") {
const hasTokenCandidate =
Boolean(env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim()) ||
Boolean(cfg.gateway?.auth?.token?.trim());
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
if (hasTokenCandidate) {
return cfg;
}
@@ -304,8 +368,10 @@ export async function resolvePairingSetupFromConfig(
cfg: OpenClawConfig,
options: ResolvePairingSetupOptions = {},
): Promise<PairingSetupResolution> {
assertExplicitGatewayAuthModeWhenBothConfigured(cfg);
const env = options.env ?? process.env;
const cfgForAuth = await resolveGatewayPasswordSecretRef(cfg, env);
const cfgWithToken = await resolveGatewayTokenSecretRef(cfg, env);
const cfgForAuth = await resolveGatewayPasswordSecretRef(cfgWithToken, env);
const auth = resolveAuth(cfgForAuth, env);
if (auth.error) {
return { ok: false, error: auth.error };