diff --git a/core/src/lib/env.ts b/core/src/lib/env.ts index 49da115..2d8540b 100644 --- a/core/src/lib/env.ts +++ b/core/src/lib/env.ts @@ -8,6 +8,8 @@ // site to async would be invasive. Browsers should pre-load values into a // sync map before any engine call. +import { log } from "./logger.js"; + type EnvProvider = (name: string) => string | undefined; let provider: EnvProvider = (name) => @@ -20,3 +22,23 @@ export function setEnvProvider(fn: EnvProvider): void { export function getEnv(name: string): string | undefined { return provider(name); } + +/** + * Expands `${VAR}` references in header values via the configured env provider. + * Shared by agent (`target.headers`) and MCP (`target.urlHeaders`) targets so + * both surfaces resolve secrets the same way. + */ +export function expandEnvInHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = v.replace(/\$\{([^}]+)\}/g, (_, name) => { + const trimmed = name.trim(); + const value = getEnv(trimmed); + if (value === undefined) { + log.warn(`header "${k}" references undefined env var "${trimmed}" — sending it as empty.`); + } + return value ?? ""; + }); + } + return out; +} diff --git a/core/src/targets/agentTarget.ts b/core/src/targets/agentTarget.ts index 4050c6a..9a5b618 100644 --- a/core/src/targets/agentTarget.ts +++ b/core/src/targets/agentTarget.ts @@ -4,7 +4,7 @@ import { captureSessionFromResponse, resolveSessionPlan, } from "./httpClient.js"; -import { getEnv } from "../lib/env.js"; +import { expandEnvInHeaders, getEnv } from "../lib/env.js"; import { invokeLocalTargetScript } from "../lib/localScriptTarget.js"; import { buildPropagatedHeaders, @@ -122,7 +122,7 @@ async function callHttp( const headers: Record = { "Content-Type": "application/json" }; if (resolvedApiKey) headers["Authorization"] = `Bearer ${resolvedApiKey}`; - if (config.headers) Object.assign(headers, config.headers); + if (config.headers) Object.assign(headers, expandEnvInHeaders(config.headers)); const prop = options?.propagation; const hasPropagation = diff --git a/core/src/targets/httpClient.ts b/core/src/targets/httpClient.ts index 998ae7a..5225860 100644 --- a/core/src/targets/httpClient.ts +++ b/core/src/targets/httpClient.ts @@ -4,6 +4,7 @@ */ import type { SessionConfig } from "../execute/types.js"; +import { expandEnvInHeaders } from "../lib/env.js"; export const REQUEST_TIMEOUT_MS = 30_000; export const RATE_LIMIT_BACKOFF_MS = 5_000; @@ -216,7 +217,7 @@ export async function httpSend( ): Promise { const headers: Record = { "Content-Type": "application/json" }; if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`; - if (config.headers) Object.assign(headers, config.headers); + if (config.headers) Object.assign(headers, expandEnvInHeaders(config.headers)); if (options.extraHeaders) Object.assign(headers, options.extraHeaders); let body: Record; diff --git a/core/src/targets/mcpTarget.ts b/core/src/targets/mcpTarget.ts index b2ae66f..f8139f1 100644 --- a/core/src/targets/mcpTarget.ts +++ b/core/src/targets/mcpTarget.ts @@ -2,6 +2,7 @@ import type { McpTargetConfig } from "../execute/types.js"; import { connectMcpClient, type McpConnectedClient } from "../mcp-client/createClient.js"; import type { ToolInfo } from "../generate/generateAttacks.js"; import type { ResourceInfo } from "../run/scanResources.js"; +import { expandEnvInHeaders } from "../lib/env.js"; export interface McpToolCallResult { response: string; @@ -27,14 +28,6 @@ export async function createMcpTarget(config: McpTargetConfig): Promise): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(headers)) { - out[k] = v.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name.trim()] ?? ""); - } - return out; -} - function buildServerConfig(config: McpTargetConfig): Parameters[0] { if (config.transport === "stdio") { if (!config.command) throw new Error("MCP stdio target requires command"); diff --git a/docs/cli.md b/docs/cli.md index 11f4742..32899a1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -332,7 +332,7 @@ See [telemetry.md](telemetry.md) for Langfuse and Netra setup, config fields, an | `target.requestFormat` | For HTTP | `"openai"`, `"json"`, or `"auto"` (default). Ignored when `target.stateful` is `false` (stateless mode forces OpenAI shape). | | `target.model` | For HTTP / openai | Model name to send in the request body. | | `target.apiKeyEnv` | No | Env var **name** holding the target's API key (e.g. `"TARGET_API_KEY"`). Never put the raw key here. | -| `target.headers` | No | Custom HTTP headers (e.g. `{"X-Api-Key": "secret"}`). Merged with built-in headers. | +| `target.headers` | No | Custom HTTP headers (e.g. `{"X-Api-Key": "${TARGET_API_KEY}"}`). Merged with built-in headers. Values support `${VAR}` expansion — use it instead of pasting secrets in directly. | | `target.promptPath` | No | Dot-path for the prompt field (e.g. `"input.message"`). Defaults to top-level `prompt`. | | `target.responsePath` | No | Dot-path to extract the reply (e.g. `"data.reply"`). Falls back to built-in chain. | | `target.sessionIdField` | No | Legacy shorthand for `session.send = { in: "body", name: … }` (client-owned, body). Still honored. | diff --git a/runners/cli/src/ui/server.ts b/runners/cli/src/ui/server.ts index e7ead0a..0a889f8 100644 --- a/runners/cli/src/ui/server.ts +++ b/runners/cli/src/ui/server.ts @@ -82,6 +82,9 @@ export interface UiServerHandle { function resolveStaticDir(): string { const candidates = [ + // Bundled prod layout: esbuild flattens src/ into a single dist/index.js, + // so __dirname is already "dist" — ui-static sits right next to it. + path.join(__dirname, "ui-static"), path.join(__dirname, "..", "ui-static"), path.join(__dirname, "..", "..", "ui-static"), path.join(process.cwd(), "dist", "ui-static"),