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

@@ -132,4 +132,29 @@ describe("ensureBrowserControlAuth", () => {
expect(result).toEqual({ auth: { token: "latest-token" } });
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("fails when gateway.auth.token SecretRef is unresolved", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
browser: {
enabled: true,
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
mocks.loadConfig.mockReturnValue(cfg);
await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
/MISSING_GW_TOKEN/i,
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -87,7 +87,10 @@ export async function ensureBrowserControlAuth(params: {
env,
persist: true,
});
const ensuredAuth = resolveBrowserControlAuth(ensured.cfg, env);
const ensuredAuth = {
token: ensured.auth.token,
password: ensured.auth.password,
};
return {
auth: ensuredAuth,
generatedToken: ensured.generatedToken,

View File

@@ -0,0 +1,117 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
const { resolveRelayAcceptedTokensForPort } = await import("./extension-relay-auth.js");
describe("extension-relay-auth SecretRef handling", () => {
const ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN", "CUSTOM_GATEWAY_TOKEN"];
const envSnapshot = new Map<string, string | undefined>();
beforeEach(() => {
for (const key of ENV_KEYS) {
envSnapshot.set(key, process.env[key]);
delete process.env[key];
}
loadConfigMock.mockReset();
});
afterEach(() => {
for (const key of ENV_KEYS) {
const previous = envSnapshot.get(key);
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("resolves env-template gateway.auth.token from its referenced env var", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } },
secrets: { providers: { default: { source: "env" } } },
});
process.env.CUSTOM_GATEWAY_TOKEN = "resolved-gateway-token";
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain("resolved-gateway-token");
expect(tokens[0]).not.toBe("resolved-gateway-token");
});
it("fails closed when env-template gateway.auth.token is unresolved", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } },
secrets: { providers: { default: { source: "env" } } },
});
await expect(resolveRelayAcceptedTokensForPort(18790)).rejects.toThrow(
"gateway.auth.token SecretRef is unavailable",
);
});
it("resolves file-backed gateway.auth.token SecretRef", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-file-secret-"));
const secretFile = path.join(tempDir, "relay-secrets.json");
await fs.writeFile(secretFile, JSON.stringify({ relayToken: "resolved-file-relay-token" }));
await fs.chmod(secretFile, 0o600);
loadConfigMock.mockReturnValue({
secrets: {
providers: {
fileProvider: { source: "file", path: secretFile, mode: "json" },
},
},
gateway: {
auth: {
token: { source: "file", provider: "fileProvider", id: "/relayToken" },
},
},
});
try {
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("resolved-file-relay-token");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolves exec-backed gateway.auth.token SecretRef", async () => {
const execProgram = [
"process.stdout.write(",
"JSON.stringify({ protocolVersion: 1, values: { RELAY_TOKEN: 'resolved-exec-relay-token' } })",
");",
].join("");
loadConfigMock.mockReturnValue({
secrets: {
providers: {
execProvider: {
source: "exec",
command: process.execPath,
args: ["-e", execProgram],
allowInsecurePath: true,
},
},
},
gateway: {
auth: {
token: { source: "exec", provider: "execProvider", id: "RELAY_TOKEN" },
},
},
});
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("resolved-exec-relay-token");
});
});

View File

@@ -60,20 +60,20 @@ describe("extension-relay-auth", () => {
}
});
it("derives deterministic relay tokens per port", () => {
const tokenA1 = resolveRelayAuthTokenForPort(18790);
const tokenA2 = resolveRelayAuthTokenForPort(18790);
const tokenB = resolveRelayAuthTokenForPort(18791);
it("derives deterministic relay tokens per port", async () => {
const tokenA1 = await resolveRelayAuthTokenForPort(18790);
const tokenA2 = await resolveRelayAuthTokenForPort(18790);
const tokenB = await resolveRelayAuthTokenForPort(18791);
expect(tokenA1).toBe(tokenA2);
expect(tokenA1).not.toBe(tokenB);
expect(tokenA1).not.toBe(TEST_GATEWAY_TOKEN);
});
it("accepts both relay-scoped and raw gateway tokens for compatibility", () => {
const tokens = resolveRelayAcceptedTokensForPort(18790);
it("accepts both relay-scoped and raw gateway tokens for compatibility", async () => {
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain(TEST_GATEWAY_TOKEN);
expect(tokens[0]).not.toBe(TEST_GATEWAY_TOKEN);
expect(tokens[0]).toBe(resolveRelayAuthTokenForPort(18790));
expect(tokens[0]).toBe(await resolveRelayAuthTokenForPort(18790));
});
it("accepts authenticated openclaw relay probe responses", async () => {
@@ -89,7 +89,7 @@ describe("extension-relay-auth", () => {
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
},
async ({ port }) => {
const token = resolveRelayAuthTokenForPort(port);
const token = await resolveRelayAuthTokenForPort(port);
const ok = await probeRelay(`http://127.0.0.1:${port}`, token);
expect(ok).toBe(true);
expect(seenToken).toBe(token);

View File

@@ -1,11 +1,26 @@
import { createHmac } from "node:crypto";
import { loadConfig } from "../config/config.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1";
const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500;
const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay";
function resolveGatewayAuthToken(): string | null {
class SecretRefUnavailableError extends Error {
readonly isSecretRefUnavailable = true;
}
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
async function resolveGatewayAuthToken(): Promise<string | null> {
const envToken =
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
if (envToken) {
@@ -13,11 +28,36 @@ function resolveGatewayAuthToken(): string | null {
}
try {
const cfg = loadConfig();
const configToken = cfg.gateway?.auth?.token?.trim();
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
if (tokenRef) {
const refLabel = `${tokenRef.source}:${tokenRef.provider}:${tokenRef.id}`;
try {
const resolved = await resolveSecretRefValues([tokenRef], {
config: cfg,
env: process.env,
});
const resolvedToken = trimToUndefined(resolved.get(secretRefKey(tokenRef)));
if (resolvedToken) {
return resolvedToken;
}
} catch {
// handled below
}
throw new SecretRefUnavailableError(
`extension relay requires a resolved gateway token, but gateway.auth.token SecretRef is unavailable (${refLabel}). Set OPENCLAW_GATEWAY_TOKEN or resolve your secret provider.`,
);
}
const configToken = normalizeSecretInputString(cfg.gateway?.auth?.token);
if (configToken) {
return configToken;
}
} catch {
} catch (err) {
if (err instanceof SecretRefUnavailableError) {
throw err;
}
// ignore config read failures; caller can fallback to per-process random token
}
return null;
@@ -27,8 +67,8 @@ function deriveRelayAuthToken(gatewayToken: string, port: number): string {
return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex");
}
export function resolveRelayAcceptedTokensForPort(port: number): string[] {
const gatewayToken = resolveGatewayAuthToken();
export async function resolveRelayAcceptedTokensForPort(port: number): Promise<string[]> {
const gatewayToken = await resolveGatewayAuthToken();
if (!gatewayToken) {
throw new Error(
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
@@ -41,8 +81,8 @@ export function resolveRelayAcceptedTokensForPort(port: number): string[] {
return [relayToken, gatewayToken];
}
export function resolveRelayAuthTokenForPort(port: number): string {
return resolveRelayAcceptedTokensForPort(port)[0];
export async function resolveRelayAuthTokenForPort(port: number): Promise<string> {
return (await resolveRelayAcceptedTokensForPort(port))[0];
}
export async function probeAuthenticatedOpenClawRelay(params: {

View File

@@ -249,8 +249,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
);
const initPromise = (async (): Promise<ChromeExtensionRelayServer> => {
const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port));
const relayAuthToken = await resolveRelayAuthTokenForPort(info.port);
const relayAuthTokens = new Set(await resolveRelayAcceptedTokensForPort(info.port));
let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>();