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

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
loadConfigMock as loadConfig,
@@ -5,10 +8,19 @@ import {
pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4,
resolveGatewayPortMock as resolveGatewayPort,
} from "../gateway/gateway-connection.test-mocks.js";
import { captureEnv, withEnv } from "../test-utils/env.js";
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
const { resolveGatewayConnection } = await import("./gateway-chat.js");
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
describe("resolveGatewayConnection", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
@@ -29,10 +41,10 @@ describe("resolveGatewayConnection", () => {
envSnapshot.restore();
});
it("throws when url override is missing explicit credentials", () => {
it("throws when url override is missing explicit credentials", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
expect(() => resolveGatewayConnection({ url: "wss://override.example/ws" })).toThrow(
await expect(resolveGatewayConnection({ url: "wss://override.example/ws" })).rejects.toThrow(
"explicit credentials",
);
});
@@ -48,10 +60,10 @@ describe("resolveGatewayConnection", () => {
auth: { password: "explicit-password" },
expected: { token: undefined, password: "explicit-password" },
},
])("uses explicit $label when url override is set", ({ auth, expected }) => {
])("uses explicit $label when url override is set", async ({ auth, expected }) => {
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
const result = resolveGatewayConnection({
const result = await resolveGatewayConnection({
url: "wss://override.example/ws",
...auth,
});
@@ -73,33 +85,98 @@ describe("resolveGatewayConnection", () => {
bind: "lan",
setup: () => pickPrimaryLanIPv4.mockReturnValue("192.168.1.42"),
},
])("uses loopback host when local bind is $label", ({ bind, setup }) => {
])("uses loopback host when local bind is $label", async ({ bind, setup }) => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind } });
resolveGatewayPort.mockReturnValue(18800);
setup();
const result = resolveGatewayConnection({});
const result = await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
return await resolveGatewayConnection({});
});
expect(result.url).toBe("ws://127.0.0.1:18800");
});
it("uses OPENCLAW_GATEWAY_TOKEN for local mode", () => {
it("uses OPENCLAW_GATEWAY_TOKEN for local mode", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
withEnv({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, () => {
const result = resolveGatewayConnection({});
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
const result = await resolveGatewayConnection({});
expect(result.token).toBe("env-token");
});
});
it("falls back to config auth token when env token is missing", () => {
it("falls back to config auth token when env token is missing", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } });
const result = resolveGatewayConnection({});
const result = await resolveGatewayConnection({});
expect(result.token).toBe("config-token");
});
it("prefers OPENCLAW_GATEWAY_PASSWORD over remote password fallback", () => {
it("uses local password auth when gateway.auth.mode is unset and password-only is configured", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
auth: {
password: "config-password",
},
},
});
const result = await resolveGatewayConnection({});
expect(result.password).toBe("config-password");
expect(result.token).toBeUndefined();
});
it("fails when both local token and password are configured but gateway.auth.mode is unset", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
auth: {
token: "config-token",
password: "config-password",
},
},
});
await expect(resolveGatewayConnection({})).rejects.toThrow(
"gateway.auth.mode is unset. Set gateway.auth.mode to token or password.",
);
});
it("resolves env-template config auth token from referenced env var", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: { token: "${CUSTOM_GATEWAY_TOKEN}" },
},
});
await withEnvAsync({ CUSTOM_GATEWAY_TOKEN: "custom-token" }, async () => {
const result = await resolveGatewayConnection({});
expect(result.token).toBe("custom-token");
});
});
it("fails with guidance when env-template config auth token is unresolved", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
auth: { token: "${MISSING_GATEWAY_TOKEN}" },
},
});
await expect(resolveGatewayConnection({})).rejects.toThrow(
"gateway.auth.token SecretRef is unresolved",
);
});
it("prefers OPENCLAW_GATEWAY_PASSWORD over remote password fallback", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "remote",
@@ -107,9 +184,181 @@ describe("resolveGatewayConnection", () => {
},
});
withEnv({ OPENCLAW_GATEWAY_PASSWORD: "env-pass" }, () => {
const result = resolveGatewayConnection({});
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-pass" }, async () => {
const result = await resolveGatewayConnection({});
expect(result.password).toBe("env-pass");
});
});
it.runIf(process.platform !== "win32")(
"resolves file-backed SecretRef token for local mode",
async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-file-secret-"));
const secretFile = path.join(tempDir, "secrets.json");
await fs.writeFile(secretFile, JSON.stringify({ gatewayToken: "file-secret-token" }), "utf8");
await fs.chmod(secretFile, 0o600);
loadConfig.mockReturnValue({
secrets: {
providers: {
fileProvider: {
source: "file",
path: secretFile,
mode: "json",
allowInsecurePath: true,
},
},
},
gateway: {
mode: "local",
auth: {
token: { source: "file", provider: "fileProvider", id: "/gatewayToken" },
},
},
});
try {
const result = await resolveGatewayConnection({});
expect(result.token).toBe("file-secret-token");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it("resolves exec-backed SecretRef token for local mode", async () => {
const execProgram = [
"process.stdout.write(",
"JSON.stringify({ protocolVersion: 1, values: { EXEC_GATEWAY_TOKEN: 'exec-secret-token' } })",
");",
].join("");
loadConfig.mockReturnValue({
secrets: {
providers: {
execProvider: {
source: "exec",
command: process.execPath,
args: ["-e", execProgram],
allowInsecurePath: true,
},
},
},
gateway: {
mode: "local",
auth: {
token: { source: "exec", provider: "execProvider", id: "EXEC_GATEWAY_TOKEN" },
},
},
});
const result = await resolveGatewayConnection({});
expect(result.token).toBe("exec-secret-token");
});
it("resolves only token SecretRef when gateway.auth.mode is token", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-token-"));
const tokenMarker = path.join(tempDir, "token-provider-ran");
const passwordMarker = path.join(tempDir, "password-provider-ran");
const tokenExecProgram = [
"const fs=require('node:fs');",
`fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`,
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));",
].join("");
const passwordExecProgram = [
"const fs=require('node:fs');",
`fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`,
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));",
].join("");
loadConfig.mockReturnValue({
secrets: {
providers: {
tokenProvider: {
source: "exec",
command: process.execPath,
args: ["-e", tokenExecProgram],
allowInsecurePath: true,
},
passwordProvider: {
source: "exec",
command: process.execPath,
args: ["-e", passwordExecProgram],
allowInsecurePath: true,
},
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" },
password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" },
},
},
});
try {
const result = await resolveGatewayConnection({});
expect(result.token).toBe("token-from-exec");
expect(result.password).toBeUndefined();
expect(await fileExists(tokenMarker)).toBe(true);
expect(await fileExists(passwordMarker)).toBe(false);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolves only password SecretRef when gateway.auth.mode is password", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-password-"));
const tokenMarker = path.join(tempDir, "token-provider-ran");
const passwordMarker = path.join(tempDir, "password-provider-ran");
const tokenExecProgram = [
"const fs=require('node:fs');",
`fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`,
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));",
].join("");
const passwordExecProgram = [
"const fs=require('node:fs');",
`fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`,
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));",
].join("");
loadConfig.mockReturnValue({
secrets: {
providers: {
tokenProvider: {
source: "exec",
command: process.execPath,
args: ["-e", tokenExecProgram],
allowInsecurePath: true,
},
passwordProvider: {
source: "exec",
command: process.execPath,
args: ["-e", passwordExecProgram],
allowInsecurePath: true,
},
},
},
gateway: {
mode: "local",
auth: {
mode: "password",
token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" },
password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" },
},
},
});
try {
const result = await resolveGatewayConnection({});
expect(result.password).toBe("password-from-exec");
expect(result.token).toBeUndefined();
expect(await fileExists(tokenMarker)).toBe(false);
expect(await fileExists(passwordMarker)).toBe(true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";
import { loadConfig } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import {
buildGatewayConnectionDetails,
ensureExplicitGatewayAuth,
@@ -14,6 +16,7 @@ import {
type SessionsPatchResult,
type SessionsPatchParams,
} from "../gateway/protocol/index.js";
import { resolveConfiguredSecretInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
@@ -39,6 +42,30 @@ export type GatewayEvent = {
seq?: number;
};
type ResolvedGatewayConnection = {
url: string;
token?: string;
password?: string;
};
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function throwGatewayAuthResolutionError(reason: string): never {
throw new Error(
[
reason,
"Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass --token/--password,",
"or resolve the configured secret provider for this credential.",
].join("\n"),
);
}
export type GatewaySessionList = {
ts: number;
path: string;
@@ -112,18 +139,17 @@ export class GatewayChatClient {
onDisconnected?: (reason: string) => void;
onGap?: (info: { expected: number; received: number }) => void;
constructor(opts: GatewayConnectionOptions) {
const resolved = resolveGatewayConnection(opts);
this.connection = resolved;
constructor(connection: ResolvedGatewayConnection) {
this.connection = connection;
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
this.client = new GatewayClient({
url: resolved.url,
token: resolved.token,
password: resolved.password,
url: connection.url,
token: connection.token,
password: connection.password,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "openclaw-tui",
clientVersion: VERSION,
@@ -158,6 +184,11 @@ export class GatewayChatClient {
});
}
static async connect(opts: GatewayConnectionOptions): Promise<GatewayChatClient> {
const connection = await resolveGatewayConnection(opts);
return new GatewayChatClient(connection);
}
start() {
this.client.start();
}
@@ -234,11 +265,16 @@ export class GatewayChatClient {
}
}
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
export async function resolveGatewayConnection(
opts: GatewayConnectionOptions,
): Promise<ResolvedGatewayConnection> {
const config = loadConfig();
const env = process.env;
const gatewayAuthMode = config.gateway?.auth?.mode;
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined;
const authToken = config.gateway?.auth?.token;
const remote = config.gateway?.remote;
const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
const urlOverride =
typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined;
@@ -254,27 +290,152 @@ export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
...(urlOverride ? { url: urlOverride } : {}),
}).url;
const token =
explicitAuth.token ||
(!urlOverride
? 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)
: undefined);
if (urlOverride) {
return {
url,
token: explicitAuth.token,
password: explicitAuth.password,
};
}
const password =
explicitAuth.password ||
(!urlOverride
? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
(typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim()
: undefined)
: undefined);
if (isRemoteMode) {
const remoteToken = explicitAuth.token
? { value: explicitAuth.token }
: await resolveConfiguredSecretInputString({
value: remote?.token,
path: "gateway.remote.token",
env,
config,
});
const remotePassword =
explicitAuth.password || envPassword
? { value: explicitAuth.password ?? envPassword }
: await resolveConfiguredSecretInputString({
value: remote?.password,
path: "gateway.remote.password",
env,
config,
});
return { url, token, password };
const token = explicitAuth.token ?? remoteToken.value;
const password = explicitAuth.password ?? envPassword ?? remotePassword.value;
if (!token && !password) {
throwGatewayAuthResolutionError(
remoteToken.unresolvedRefReason ??
remotePassword.unresolvedRefReason ??
"Missing gateway auth credentials.",
);
}
return { url, token, password };
}
if (gatewayAuthMode === "none" || gatewayAuthMode === "trusted-proxy") {
return {
url,
token: explicitAuth.token ?? envToken,
password: explicitAuth.password ?? envPassword,
};
}
try {
assertExplicitGatewayAuthModeWhenBothConfigured(config);
} catch (err) {
throwGatewayAuthResolutionError(err instanceof Error ? err.message : String(err));
}
const defaults = config.secrets?.defaults;
const hasConfiguredToken = hasConfiguredSecretInput(config.gateway?.auth?.token, defaults);
const hasConfiguredPassword = hasConfiguredSecretInput(config.gateway?.auth?.password, defaults);
if (gatewayAuthMode === "password") {
const localPassword =
explicitAuth.password || envPassword
? { value: explicitAuth.password ?? envPassword }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.password,
path: "gateway.auth.password",
env,
config,
});
const password = explicitAuth.password ?? envPassword ?? localPassword.value;
if (!password) {
throwGatewayAuthResolutionError(
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",
);
}
return {
url,
token: explicitAuth.token ?? envToken,
password,
};
}
if (gatewayAuthMode === "token") {
const localToken =
explicitAuth.token || envToken
? { value: explicitAuth.token ?? envToken }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.token,
path: "gateway.auth.token",
env,
config,
});
const token = explicitAuth.token ?? envToken ?? localToken.value;
if (!token) {
throwGatewayAuthResolutionError(
localToken.unresolvedRefReason ?? "Missing gateway auth token.",
);
}
return {
url,
token,
password: explicitAuth.password ?? envPassword,
};
}
const passwordCandidate = explicitAuth.password ?? envPassword;
const shouldUsePassword =
Boolean(passwordCandidate) || (hasConfiguredPassword && !hasConfiguredToken);
if (shouldUsePassword) {
const localPassword = passwordCandidate
? { value: passwordCandidate }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.password,
path: "gateway.auth.password",
env,
config,
});
const password = passwordCandidate ?? localPassword.value;
if (!password) {
throwGatewayAuthResolutionError(
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",
);
}
return {
url,
token: explicitAuth.token ?? envToken,
password,
};
}
const localToken =
explicitAuth.token || envToken
? { value: explicitAuth.token ?? envToken }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.token,
path: "gateway.auth.token",
env,
config,
});
const token = explicitAuth.token ?? envToken ?? localToken.value;
if (!token) {
throwGatewayAuthResolutionError(
localToken.unresolvedRefReason ?? "Missing gateway auth token.",
);
}
return {
url,
token,
password: explicitAuth.password ?? envPassword,
};
}

View File

@@ -471,7 +471,7 @@ export async function runTui(opts: TuiOptions) {
localRunIds.clear();
};
const client = new GatewayChatClient({
const client = await GatewayChatClient.connect({
url: opts.url,
token: opts.token,
password: opts.password,