mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 09:33:06 +00:00
Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
117
src/browser/extension-relay-auth.secretref.test.ts
Normal file
117
src/browser/extension-relay-auth.secretref.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user