@workos/quickstudy 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,6 +118,32 @@ experiment module — changing them changes the experiment's identity, by
118
118
  design. See the benchmark repository's `examples/weekly-sweep.yml` for what
119
119
  a full scheduled sweep costs and looks like in CI.
120
120
 
121
+ ### Routing through an LLM gateway
122
+
123
+ The vendor CLIs and the diagnosis analyst all honor endpoint overrides, so a
124
+ gateway or proxy (Cloudflare AI Gateway, LiteLLM, a corporate egress) is an
125
+ env change, not a code change. Each adapter declares its overrides
126
+ (`optionalHostEnv`); the harness forwards them into attempt containers only
127
+ when set, adds a base URL's host to the egress allowlist, and points the
128
+ preflight key probe at it:
129
+
130
+ | Agent | Overrides | How the CLI gets them |
131
+ | ------ | ------------------------------------------------ | -------------------------------------------------------------------------------------- |
132
+ | claude | `ANTHROPIC_BASE_URL`, `ANTHROPIC_CUSTOM_HEADERS` | env, read natively by Claude Code (and by the `diagnose` analyst) |
133
+ | codex | `OPENAI_BASE_URL` | rendered into `config.toml` as a custom model provider (codex ignores the env var) |
134
+
135
+ `ANTHROPIC_CUSTOM_HEADERS` is `Name: Value` lines and is redacted from
136
+ artifacts like a key. With Cloudflare AI Gateway holding the provider keys,
137
+ the gateway token *is* the API key:
138
+
139
+ ```bash
140
+ export ANTHROPIC_BASE_URL=https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic
141
+ export ANTHROPIC_API_KEY=<gateway token>
142
+ export ANTHROPIC_CUSTOM_HEADERS="cf-aig-authorization: Bearer <gateway token>"
143
+ export OPENAI_BASE_URL=https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/openai
144
+ export OPENAI_API_KEY=<gateway token>
145
+ ```
146
+
121
147
  ## How it works
122
148
 
123
149
  Every attempt is one point in a 2-D plan:
@@ -181,7 +207,9 @@ failing pair's harness-selected evidence (per-check tallies, transcript
181
207
  excerpts, egress denials) to a structured LLM call and writes `diagnosis.json`
182
208
  beside `report.json` — findings are evidence-linked hypotheses, never
183
209
  verdicts, keeping the report itself attribution-free. Requires
184
- `ANTHROPIC_API_KEY`; the UI renders the result as a Diagnosis view.
210
+ `ANTHROPIC_API_KEY` (and follows the claude adapter's gateway overrides, see
211
+ [Routing through an LLM gateway](#routing-through-an-llm-gateway)); the UI
212
+ renders the result as a Diagnosis view.
185
213
 
186
214
  ## Writing evals
187
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { McpServerConfig } from "../isolation/mcp.ts";
17
+ import { endpointUrl, parseHeaderLines } from "../secrets.ts";
17
18
  import { parseClaudeStream } from "./parse.ts";
18
19
  import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
19
20
 
@@ -38,10 +39,21 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
38
39
  readonly identity;
39
40
  readonly versionCommand = ["claude", "--version"] as const;
40
41
  readonly requiredHostEnv = ["ANTHROPIC_API_KEY"] as const;
42
+ /**
43
+ * Endpoint overrides, forwarded only when set. ANTHROPIC_BASE_URL routes
44
+ * the CLI (and the key probe) through an Anthropic-compatible gateway —
45
+ * e.g. Cloudflare AI Gateway's `/anthropic` endpoint, where the gateway
46
+ * token doubles as ANTHROPIC_API_KEY; ANTHROPIC_CUSTOM_HEADERS (`Name:
47
+ * Value` lines) carries whatever else that gateway wants, such as
48
+ * `cf-aig-authorization` or `cf-aig-metadata`. Claude Code reads both
49
+ * natively, so no flag changes.
50
+ */
51
+ readonly optionalHostEnv = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS"] as const;
41
52
  /**
42
53
  * Egress-proxy allowlist (harness-default layer). Claude Code documents
43
54
  * corporate-proxy support via HTTPS_PROXY/HTTP_PROXY; api.anthropic.com is
44
- * the LLM endpoint, statsig/sentry are its telemetry (allowed so denial
55
+ * the default LLM endpoint (an ANTHROPIC_BASE_URL override adds its own
56
+ * host at run time), statsig/sentry are its telemetry (allowed so denial
45
57
  * logs stay signal, not noise). Verified live by the egress-proxy regression
46
58
  * check (`bun scripts/dev-attempt.ts --experiment <id> --egress-proxy`).
47
59
  */
@@ -66,8 +78,12 @@ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
66
78
 
67
79
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
68
80
  return {
69
- url: "https://api.anthropic.com/v1/models",
70
- headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "anthropic-version": "2023-06-01" },
81
+ url: endpointUrl(env["ANTHROPIC_BASE_URL"], "https://api.anthropic.com", "v1/models"),
82
+ headers: {
83
+ ...parseHeaderLines(env["ANTHROPIC_CUSTOM_HEADERS"]),
84
+ "x-api-key": env["ANTHROPIC_API_KEY"] ?? "",
85
+ "anthropic-version": "2023-06-01",
86
+ },
71
87
  };
72
88
  }
73
89
 
@@ -9,15 +9,43 @@
9
9
  * container create, but codex >= 0.144 no longer reads it directly — the
10
10
  * command wrapper pipes it into `codex login --with-api-key` (stdin, never
11
11
  * argv) to write the auth.json the CLI requires.
12
+ *
13
+ * Endpoint override: an OPENAI_BASE_URL set on the host (an LLM gateway) is
14
+ * rendered into the same config.toml as a custom model provider — codex
15
+ * 0.149 ignores the env var for its built-in provider (verified live: it
16
+ * still dialed api.openai.com), while a `[model_providers.*]` entry with
17
+ * `env_key = "OPENAI_API_KEY"` routes every request through the override.
12
18
  */
13
19
 
14
20
  import type { McpServerConfig } from "../isolation/mcp.ts";
21
+ import { endpointUrl } from "../secrets.ts";
15
22
  import { parseCodexStream } from "./parse.ts";
16
23
  import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
17
24
 
18
25
  /** Container path of the Codex config (containers run as root). */
19
26
  export const CODEX_CONFIG_PATH = "/root/.codex/config.toml";
20
27
 
28
+ /** The config.toml model provider id an OPENAI_BASE_URL override is rendered as. */
29
+ export const CODEX_GATEWAY_PROVIDER = "quickstudy-gateway";
30
+
31
+ /**
32
+ * A custom model provider pointing at `baseUrl` (the Responses API, which
33
+ * codex requires), selected as the default. Top-level keys must precede any
34
+ * table, so this block always renders first in the file.
35
+ */
36
+ function renderCodexProviderToml(baseUrl: string): string {
37
+ return [
38
+ `model_provider = ${JSON.stringify(CODEX_GATEWAY_PROVIDER)}`,
39
+ "",
40
+ `[model_providers.${CODEX_GATEWAY_PROVIDER}]`,
41
+ 'name = "OpenAI (via OPENAI_BASE_URL)"',
42
+ `base_url = ${JSON.stringify(baseUrl)}`,
43
+ 'env_key = "OPENAI_API_KEY"',
44
+ 'wire_api = "responses"',
45
+ "",
46
+ ].join("\n");
47
+ }
48
+
21
49
  /**
22
50
  * `config.toml` MCP table rendered from the runtime's MCP servers (url-based,
23
51
  * credential-free — authed servers already point at the token-proxy sidecar).
@@ -37,13 +65,22 @@ export class CodexAdapter implements ContainerAgentAdapter {
37
65
  readonly identity;
38
66
  readonly versionCommand = ["codex", "--version"] as const;
39
67
  readonly requiredHostEnv = ["OPENAI_API_KEY"] as const;
68
+ /**
69
+ * Endpoint override, forwarded only when set: OPENAI_BASE_URL (vendor
70
+ * default https://api.openai.com/v1) — e.g. Cloudflare AI Gateway's
71
+ * `/openai` endpoint, where the gateway token doubles as OPENAI_API_KEY.
72
+ * Rendered into config.toml by setupFiles (see the module comment); the
73
+ * key probe follows it.
74
+ */
75
+ readonly optionalHostEnv = ["OPENAI_BASE_URL"] as const;
40
76
  /**
41
77
  * Egress-proxy allowlist (harness-default layer). Codex is reqwest-based;
42
78
  * reqwest honors HTTP_PROXY/HTTPS_PROXY env by default (system proxies) —
43
79
  * verified live by the egress-proxy regression check (the CONNECT tunnel
44
80
  * to api.openai.com round-tripped). chatgpt.com came from that run's
45
81
  * denial log: codex probes its ChatGPT-auth endpoints even under key auth.
46
- * (Its github.com update-check denials are left denied on purpose.)
82
+ * (Its github.com update-check denials are left denied on purpose.) An
83
+ * OPENAI_BASE_URL override adds its own host at run time.
47
84
  */
48
85
  readonly egressHosts = ["api.openai.com", "auth.openai.com", "chatgpt.com", "*.chatgpt.com"] as const;
49
86
 
@@ -66,14 +103,18 @@ export class CodexAdapter implements ContainerAgentAdapter {
66
103
 
67
104
  keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
68
105
  return {
69
- url: "https://api.openai.com/v1/models",
106
+ url: endpointUrl(env["OPENAI_BASE_URL"], "https://api.openai.com/v1", "models"),
70
107
  headers: { Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
71
108
  };
72
109
  }
73
110
 
74
111
  setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
75
- if (ctx.mcpServers === undefined) return {};
76
- return { [CODEX_CONFIG_PATH]: renderCodexMcpToml(ctx.mcpServers) };
112
+ const sections: string[] = [];
113
+ const baseUrl = ctx.overrides?.["OPENAI_BASE_URL"];
114
+ if (baseUrl !== undefined) sections.push(renderCodexProviderToml(baseUrl));
115
+ if (ctx.mcpServers !== undefined) sections.push(renderCodexMcpToml(ctx.mcpServers));
116
+ if (sections.length === 0) return {};
117
+ return { [CODEX_CONFIG_PATH]: sections.join("\n") };
77
118
  }
78
119
 
79
120
  command(ctx: ContainerAttemptContext): string[] {
@@ -132,6 +132,13 @@ export interface ContainerAttemptContext {
132
132
  mcpServers?: Readonly<Record<string, McpServerConfig>>;
133
133
  /** The runtime's native web-tool policy (default: blocked). */
134
134
  webPolicy: WebPolicy;
135
+ /**
136
+ * The adapter's endpoint overrides that are set on the host (its
137
+ * `optionalHostEnv` minus secret-named entries — never provider keys or
138
+ * header bundles), for a CLI that takes a config-file entry rather than an
139
+ * env var. Absent when none are set.
140
+ */
141
+ overrides?: Readonly<Record<string, string>>;
135
142
  }
136
143
 
137
144
  /**
@@ -153,10 +160,11 @@ export interface ContainerAgentAdapter {
153
160
  /** Command used during preflight to record the executable's exact version. */
154
161
  versionCommand?: readonly string[];
155
162
  /**
156
- * Cheap authenticated request composed from the collected provider keys,
157
- * probed at run preflight so a present-but-rejected key aborts BEFORE any
158
- * container spend. A 401/403 response stops the run; network failures only
159
- * warn — an unreachable probe must not gate a run the agent might complete.
163
+ * Cheap authenticated request composed from the collected provider keys
164
+ * (plus any `optionalHostEnv` overrides that are set), probed at run
165
+ * preflight so a present-but-rejected key aborts BEFORE any container
166
+ * spend. A 401/403 response stops the run; network failures only warn — an
167
+ * unreachable probe must not gate a run the agent might complete.
160
168
  */
161
169
  keyProbe?(env: Record<string, string>): { url: string; headers: Record<string, string> };
162
170
  /**
@@ -164,6 +172,15 @@ export interface ContainerAgentAdapter {
164
172
  * (shared provider keys — see src/secrets.ts).
165
173
  */
166
174
  requiredHostEnv: readonly string[];
175
+ /**
176
+ * Host env vars forwarded into the container when set, never required:
177
+ * endpoint overrides such as a vendor base URL that routes the CLI through
178
+ * an LLM gateway or proxy, and the custom headers that gateway needs. A
179
+ * value that is an absolute URL also adds its host to the egress allowlist
180
+ * and retargets the key probe; secret-named values (headers carrying a
181
+ * bearer token) are redacted from artifacts like provider keys.
182
+ */
183
+ optionalHostEnv?: readonly string[];
167
184
  /**
168
185
  * Hosts this agent's CLI must reach (its LLM/vendor endpoints) — the
169
186
  * harness-default layer of the egress allowlist when the run uses the
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.8.0", // x-release-please-version
12
+ version: "0.9.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
@@ -27,6 +27,13 @@ export interface PairEnvironment {
27
27
  /** Runtime text prepended to the eval prompt for this pair (null = prompt verbatim). */
28
28
  promptPreamble: string | null;
29
29
  budgets: LifecycleBudgets;
30
+ /**
31
+ * The agent's endpoint overrides in force (non-secret base URLs, e.g.
32
+ * OPENAI_BASE_URL → an LLM gateway route), recorded in full so a changed
33
+ * route changes identity and a resume cannot mix routes. Absent when the
34
+ * agent runs vendor-direct — such pairs hash exactly as before.
35
+ */
36
+ endpoints?: Record<string, string>;
30
37
  }
31
38
 
32
39
  /** The prompt an agent receives: the runtime's preamble (if any), a blank line, the eval's verbatim body. */
@@ -43,6 +50,8 @@ export function resolvePairEnvironments(options: {
43
50
  images?: Record<string, RuntimeImageIdentity>;
44
51
  egressProxy?: boolean;
45
52
  adapterHosts?: Record<string, readonly string[]>;
53
+ /** Per experiment: the agent's non-secret endpoint overrides (see PairEnvironment.endpoints). */
54
+ adapterEndpoints?: Record<string, Record<string, string>>;
46
55
  budgets?: Partial<LifecycleBudgets>;
47
56
  mcpHashes?: Record<string, string>;
48
57
  imageTags?: Record<string, string>;
@@ -77,6 +86,10 @@ export function resolvePairEnvironments(options: {
77
86
  return text === "" ? null : text;
78
87
  })(),
79
88
  budgets: { ...budgets },
89
+ ...(() => {
90
+ const endpoints = options.adapterEndpoints?.[experiment.id];
91
+ return endpoints !== undefined && Object.keys(endpoints).length > 0 ? { endpoints: { ...endpoints } } : {};
92
+ })(),
80
93
  };
81
94
  }
82
95
  return pairs;
@@ -59,15 +59,20 @@ export interface ExecResult {
59
59
  timedOut: boolean;
60
60
  }
61
61
 
62
- /** Run `docker <args>`, capturing output. Never throws for non-zero exits. */
62
+ /**
63
+ * Run `docker <args>`, capturing output. Never throws for non-zero exits.
64
+ * `env` is added to the docker CLI's own environment — the channel for
65
+ * values named on argv by `-e KEY` alone.
66
+ */
63
67
  async function runDocker(
64
68
  args: string[],
65
- opts: { timeoutMs?: number; stdin?: string } = {},
69
+ opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
66
70
  ): Promise<ExecResult> {
67
71
  const proc = Bun.spawn(["docker", ...args], {
68
72
  stdin: opts.stdin === undefined ? "ignore" : Buffer.from(opts.stdin),
69
73
  stdout: "pipe",
70
74
  stderr: "pipe",
75
+ ...(opts.env !== undefined ? { env: { ...process.env, ...opts.env } } : {}),
71
76
  });
72
77
  let timedOut = false;
73
78
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -87,7 +92,10 @@ async function runDocker(
87
92
  }
88
93
 
89
94
  /** Run `docker <args>` and throw a DockerError on non-zero exit. */
90
- async function mustDocker(args: string[], opts: { timeoutMs?: number; stdin?: string } = {}): Promise<ExecResult> {
95
+ async function mustDocker(
96
+ args: string[],
97
+ opts: { timeoutMs?: number; stdin?: string; env?: Record<string, string> } = {},
98
+ ): Promise<ExecResult> {
91
99
  const result = await runDocker(args, opts);
92
100
  if (result.exitCode !== 0) throw new DockerError(args, result.exitCode, result.stderr);
93
101
  return result;
@@ -198,36 +206,55 @@ export interface AttemptContainer {
198
206
  * fixture into /workspace. On any failure the partial container is removed
199
207
  * before the error propagates — a failed create never leaks a container.
200
208
  */
209
+ /**
210
+ * Route an attempt's env between its two argv-free channels. Single-line
211
+ * values become env-file lines; a value that itself contains newlines (a
212
+ * `Name: Value` header bundle) cannot be expressed in a line-based env-file,
213
+ * so it rides the docker CLI's own environment instead, named on argv by
214
+ * `-e KEY` alone — the CLI forwards the value intact and argv never shows it.
215
+ */
216
+ export function splitAttemptEnv(env: Record<string, string>): { envFileLines: string[]; inherited: Record<string, string> } {
217
+ const envFileLines: string[] = [];
218
+ const inherited: Record<string, string> = {};
219
+ for (const [key, value] of Object.entries(env)) {
220
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid environment variable name "${key}"`);
221
+ if (value.includes("\n")) inherited[key] = value;
222
+ else envFileLines.push(`${key}=${value}`);
223
+ }
224
+ return { envFileLines, inherited };
225
+ }
226
+
201
227
  export async function createAttemptContainer(opts: CreateAttemptContainerOptions): Promise<AttemptContainer> {
202
228
  // Secrets travel via a transient env-file, not argv (argv is visible in
203
- // host process listings while `docker run` executes).
229
+ // host process listings while `docker run` executes); multi-line values
230
+ // take the CLI-environment channel (see splitAttemptEnv).
204
231
  const envDir = await mkdtemp(join(tmpdir(), "quickstudy-env-"));
205
232
  const envFile = join(envDir, "attempt.env");
206
- const lines = Object.entries(opts.env).map(([key, value]) => {
207
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid environment variable name "${key}"`);
208
- if (value.includes("\n")) throw new Error(`environment variable "${key}" contains a newline`);
209
- return `${key}=${value}`;
210
- });
211
- await writeFile(envFile, lines.join("\n") + (lines.length > 0 ? "\n" : ""), { mode: 0o600 });
233
+ const { envFileLines, inherited } = splitAttemptEnv(opts.env);
234
+ await writeFile(envFile, envFileLines.join("\n") + (envFileLines.length > 0 ? "\n" : ""), { mode: 0o600 });
212
235
 
213
236
  let id: string;
214
237
  try {
215
- const created = await mustDocker([
216
- "run",
217
- "-d",
218
- "--label",
219
- `${ATTEMPT_LABEL}=${opts.attemptId}`,
220
- "--env-file",
221
- envFile,
222
- "-v",
223
- `${resolve(opts.resultsDir)}:${CONTAINER_RESULTS}`,
224
- "-w",
225
- CONTAINER_WORKSPACE,
226
- ...(opts.network !== undefined ? ["--network", opts.network] : []),
227
- opts.image,
228
- "sleep",
229
- "infinity",
230
- ]);
238
+ const created = await mustDocker(
239
+ [
240
+ "run",
241
+ "-d",
242
+ "--label",
243
+ `${ATTEMPT_LABEL}=${opts.attemptId}`,
244
+ "--env-file",
245
+ envFile,
246
+ ...Object.keys(inherited).flatMap((key) => ["-e", key]),
247
+ "-v",
248
+ `${resolve(opts.resultsDir)}:${CONTAINER_RESULTS}`,
249
+ "-w",
250
+ CONTAINER_WORKSPACE,
251
+ ...(opts.network !== undefined ? ["--network", opts.network] : []),
252
+ opts.image,
253
+ "sleep",
254
+ "infinity",
255
+ ],
256
+ { env: inherited },
257
+ );
231
258
  id = created.stdout.trim();
232
259
  } catch (err) {
233
260
  // `docker run` can fail AFTER creating the container (start failure) —
package/src/llm.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import Anthropic from "@anthropic-ai/sdk";
16
16
  import type { z } from "zod";
17
+ import { parseHeaderLines } from "./secrets.ts";
17
18
 
18
19
  /** One structured-output request. `schema` is a JSON Schema object. */
19
20
  export interface StructuredRequest {
@@ -47,16 +48,23 @@ export class ModelOutputError extends Error {
47
48
  const API_MAX_RETRIES = 3;
48
49
 
49
50
  /**
50
- * Anthropic-backed client. Reuses ANTHROPIC_API_KEY from the environment;
51
- * transport-level retries are the SDK's (429/5xx/connection errors).
51
+ * Anthropic-backed client. Reuses ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL
52
+ * from the environment (the SDK reads both), plus ANTHROPIC_CUSTOM_HEADERS
53
+ * (`Name: Value` lines — Claude Code's convention) as default headers, so the
54
+ * analyst takes the same route as the agent CLI when both are pointed at an
55
+ * LLM gateway. Transport-level retries are the SDK's (429/5xx/connection
56
+ * errors).
52
57
  */
53
58
  export class AnthropicModelClient implements StructuredModelClient {
54
59
  private readonly client: Anthropic;
55
60
  private lastResolvedModel: string | null = null;
56
61
 
57
- constructor(options: { apiKey?: string } = {}) {
62
+ constructor(options: { apiKey?: string; baseURL?: string; defaultHeaders?: Record<string, string> } = {}) {
63
+ const defaultHeaders = options.defaultHeaders ?? parseHeaderLines(process.env["ANTHROPIC_CUSTOM_HEADERS"]);
58
64
  this.client = new Anthropic({
59
65
  ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
66
+ ...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
67
+ ...(Object.keys(defaultHeaders).length > 0 ? { defaultHeaders } : {}),
60
68
  maxRetries: API_MAX_RETRIES,
61
69
  });
62
70
  }
package/src/probe.ts CHANGED
@@ -196,14 +196,41 @@ export async function readAppLogTail(exec: ProbeExecFn, lines = 40): Promise<str
196
196
  }
197
197
 
198
198
  /** Kill the backgrounded dev server (process group first, pid as fallback). */
199
+ /**
200
+ * Every process in the app's process group, read from /proc so no `ps`,
201
+ * `pkill` or external `kill` is needed. dash's builtin `kill` rejects a
202
+ * negative pid ("Illegal number"), so `kill -- -<pgid>` only ever worked
203
+ * under bash and busybox; on the Debian-based sandbox images it failed and
204
+ * the fallback signalled the group leader alone.
205
+ */
206
+ const GROUP_MEMBERS_SH =
207
+ // After the ")" that closes the command name, /proc/<pid>/stat reads
208
+ // "<state> <ppid> <pgrp> …"; a zombie (state Z) is dead and only unreaped.
209
+ 'members() { for d in /proc/[0-9]*; do [ -r "$d/stat" ] || continue; ' +
210
+ 'set -- $(sed "s/^.*) //" "$d/stat" 2>/dev/null); ' +
211
+ '[ "$3" = "$pid" ] && [ "$1" != "Z" ] && echo "${d#/proc/}"; done; }; ' +
212
+ // Without /proc (a host runtime on macOS) fall back to the shell's group kill.
213
+ 'signal() { if [ -d /proc/self ]; then m=$(members); [ -n "$m" ] || return 1; kill -s "$1" $m 2>/dev/null; ' +
214
+ 'else kill -s "$1" -- "-$pid" 2>/dev/null; fi; }; ' +
215
+ 'alive() { if [ -d /proc/self ]; then [ -n "$(members)" ]; else kill -0 -- "-$pid" 2>/dev/null; fi; }; ';
216
+
199
217
  export async function stopBackgroundApp(exec: ProbeExecFn): Promise<void> {
218
+ // SIGTERM the group, give it five seconds, then SIGKILL whatever is left.
219
+ // Dev servers that fork a worker outlive a TERM to the leader: Next.js's
220
+ // `start-server` child stayed bound to the port after `npm run dev` exited,
221
+ // so the next probe booted on another port while the stale server answered
222
+ // the probe's requests (500s on routes it had never compiled).
200
223
  await exec(
201
224
  [
202
225
  "sh",
203
226
  "-c",
204
- `pid="$(cat ${APP_PID_PATH} 2>/dev/null)"; [ -z "$pid" ] || kill -TERM -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true`,
227
+ `pid="$(cat ${APP_PID_PATH} 2>/dev/null)"; [ -n "$pid" ] || exit 0; ` +
228
+ GROUP_MEMBERS_SH +
229
+ 'signal TERM || kill -s TERM "$pid" 2>/dev/null || true; ' +
230
+ 'i=0; while [ "$i" -lt 10 ] && alive; do sleep 0.5; i=$((i + 1)); done; ' +
231
+ 'signal KILL || kill -s KILL "$pid" 2>/dev/null || true',
205
232
  ],
206
- { timeoutMs: 10_000 },
233
+ { timeoutMs: 20_000 },
207
234
  );
208
235
  }
209
236
 
@@ -70,7 +70,7 @@ import { attemptIdentityHash, buildRunManifest, type RunManifest, type RuntimeIm
70
70
  import { isMovingModelAlias } from "../model-identity.ts";
71
71
  import type { AttemptPlan } from "../plan.ts";
72
72
  import type { ProvisionedEnvironment, RuntimeProvisionContext } from "../runtime/types.ts";
73
- import { collectProviderKeys, hostSecretEnv, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
73
+ import { collectOptionalHostEnv, collectProviderKeys, endpointHosts, headerBundleSecrets, hostSecretEnv, isSecretEnvName, redactSecrets, validateProviderKeys, type ProviderKeyProbe } from "../secrets.ts";
74
74
  import { ArtifactsStore } from "../store/artifacts.ts";
75
75
  import { activeAttempts, type ErrorKind, type NewAttempt, type ResultsStore, type RunConfig } from "../store/db.ts";
76
76
  import { acquireRunLock } from "./run-lock.ts";
@@ -402,7 +402,7 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
402
402
  probesByAdapter.set(adapter.name, {
403
403
  name: adapter.name,
404
404
  envVars: adapter.requiredHostEnv,
405
- ...adapter.keyProbe(collectProviderKeys([adapter])),
405
+ ...adapter.keyProbe({ ...collectProviderKeys([adapter]), ...collectOptionalHostEnv([adapter]) }),
406
406
  });
407
407
  }
408
408
  await validateProviderKeys([...probesByAdapter.values()], { log });
@@ -458,8 +458,22 @@ export async function executeRunV3(options: ExecuteRunV3Options): Promise<RunSum
458
458
  const scorer = await importEvalScorer(loaded.scorerPath);
459
459
  if (scorer.identity) scorerIdentities[loaded.metadata.id] = scorer.identity;
460
460
  }
461
+ // Each agent's endpoint overrides set on the host: the non-secret base
462
+ // URLs join the pair environment (identity), their hosts join the egress
463
+ // allowlist — but only under the proxy, where the allowlist is live and an
464
+ // IP-literal gateway is refused loudly by design; without it the list is
465
+ // inert and an IP-literal override must not stop the run.
466
+ const overridesByExperiment = new Map([...containerAgents].map(([id, adapter]) => [id, collectOptionalHostEnv([adapter])] as const));
467
+ const adapterEndpoints = Object.fromEntries([...overridesByExperiment].flatMap(([id, overrides]) => {
468
+ const endpoints = Object.fromEntries(Object.entries(overrides).filter(([name]) => !isSecretEnvName(name)));
469
+ return Object.keys(endpoints).length > 0 ? [[id, endpoints] as const] : [];
470
+ }));
461
471
  const pairs = resolvePairEnvironments({ ...options, images, imageTags: imageTagsByPair,
462
- adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, adapter.egressHosts ?? []])),
472
+ adapterHosts: Object.fromEntries([...containerAgents].map(([id, adapter]) => [id, [
473
+ ...(adapter.egressHosts ?? []),
474
+ ...(options.egressProxy ? endpointHosts(overridesByExperiment.get(id) ?? {}) : []),
475
+ ]])),
476
+ adapterEndpoints,
463
477
  budgets: { ...options.budgets, agentMs: options.attemptTimeoutMs ?? options.budgets?.agentMs ?? attemptTimeoutMs },
464
478
  mcpHashes: Object.fromEntries([...declaredByExperiment].map(([id, servers]) => [id, hashString(canonicalJson(servers))])),
465
479
  });
@@ -806,12 +820,26 @@ async function runContainerAttemptV3(
806
820
  // every auth block and pointed authed servers at the proxy sidecar.
807
821
  env["QUICKSTUDY_MCP_SERVERS"] = JSON.stringify(rewrittenServers);
808
822
  }
823
+ // Endpoint overrides set on the host for this agent (gateway base URLs
824
+ // and the headers they need) — see ContainerAgentAdapter.optionalHostEnv.
825
+ const overrides = agentAdapter !== undefined ? collectOptionalHostEnv([agentAdapter]) : {};
826
+ const publicOverrides = Object.fromEntries(Object.entries(overrides).filter(([name]) => !isSecretEnvName(name)));
809
827
  if (agentAdapter !== undefined) {
810
828
  // The agent's provider keys, validated at run preflight — injected
811
829
  // at create via the 0600 env-file channel, never argv.
812
830
  const providerKeys = collectProviderKeys([agentAdapter]);
813
831
  Object.assign(env, providerKeys);
814
832
  Object.assign(secretEnv, providerKeys);
833
+ // The overrides ride the same channel; only the secret-named ones
834
+ // feed redaction — a base URL in a transcript is diagnostic, a
835
+ // bearer header is not. A header bundle's credentials are targets of
836
+ // their own: a transcript that prints just the token is still scrubbed.
837
+ Object.assign(env, overrides);
838
+ for (const [name, value] of Object.entries(overrides)) {
839
+ if (!isSecretEnvName(name)) continue;
840
+ secretEnv[name] = value;
841
+ Object.assign(secretEnv, headerBundleSecrets(name, value));
842
+ }
815
843
  }
816
844
  if (egress) {
817
845
  const proxyEnv = egress.sidecar.proxyEnv();
@@ -874,6 +902,9 @@ async function runContainerAttemptV3(
874
902
  trialIndex: entry.trialIndex,
875
903
  ...(rewrittenServers !== undefined ? { mcpServers: rewrittenServers } : {}),
876
904
  webPolicy: environment?.webPolicy ?? "native-web-blocked",
905
+ // Never the keys or header bundles: adapters whose CLI wants a
906
+ // config-file entry (codex's model provider) render from here.
907
+ ...(Object.keys(publicOverrides).length > 0 ? { overrides: publicOverrides } : {}),
877
908
  };
878
909
  // Treatment config files (e.g. the MCP treatment's vendor config).
879
910
  await inStage("environment", async () => {
package/src/secrets.ts CHANGED
@@ -48,6 +48,12 @@ export interface ProviderKeyRequirement {
48
48
  name: string;
49
49
  /** Host env var names the adapter requires. */
50
50
  requiredHostEnv: readonly string[];
51
+ /**
52
+ * Host env var names forwarded when set and never required: endpoint
53
+ * overrides (a vendor base URL that routes the CLI through an LLM gateway
54
+ * or proxy) and the custom headers such a gateway needs.
55
+ */
56
+ optionalHostEnv?: readonly string[];
51
57
  }
52
58
 
53
59
  /** One preflight probe: a cheap authenticated GET a provider will 401 on a bad key. */
@@ -128,6 +134,111 @@ export function collectProviderKeys(
128
134
  return keys;
129
135
  }
130
136
 
137
+ /**
138
+ * Read every OPTIONAL host env var the adapters declare (see
139
+ * `ProviderKeyRequirement.optionalHostEnv`). Absent or empty variables are
140
+ * simply not forwarded — an empty `ANTHROPIC_BASE_URL=` from a CI template
141
+ * must not reach a CLI that would treat "" as a base URL.
142
+ */
143
+ export function collectOptionalHostEnv(
144
+ requirements: readonly ProviderKeyRequirement[],
145
+ hostEnv: Record<string, string | undefined> = process.env,
146
+ ): Record<string, string> {
147
+ const env: Record<string, string> = {};
148
+ for (const requirement of requirements) {
149
+ for (const envVar of requirement.optionalHostEnv ?? []) {
150
+ const value = hostEnv[envVar];
151
+ if (value !== undefined && value !== "") env[envVar] = value;
152
+ }
153
+ }
154
+ return env;
155
+ }
156
+
157
+ /**
158
+ * Hostnames of every forwarded value that is an absolute http(s) URL — the
159
+ * endpoints an override points at, which the egress allowlist must admit
160
+ * alongside the adapter's vendor defaults. Non-URL values (header lines)
161
+ * contribute nothing.
162
+ */
163
+ export function endpointHosts(env: Record<string, string>): string[] {
164
+ const hosts: string[] = [];
165
+ for (const value of Object.values(env)) {
166
+ let url: URL;
167
+ try {
168
+ url = new URL(value);
169
+ } catch {
170
+ continue;
171
+ }
172
+ if ((url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "") hosts.push(url.hostname);
173
+ }
174
+ return hosts;
175
+ }
176
+
177
+ /**
178
+ * `<base>/<path>` for a probe: the override when set (trailing slashes
179
+ * tolerated — gateways document both forms), else the vendor's own base.
180
+ */
181
+ export function endpointUrl(override: string | undefined, vendorBase: string, path: string): string {
182
+ const base = override !== undefined && override !== "" ? override : vendorBase;
183
+ return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
184
+ }
185
+
186
+ /**
187
+ * Parse `Name: Value` header lines (newline-separated — the format Claude
188
+ * Code reads from ANTHROPIC_CUSTOM_HEADERS). Blank lines and lines without a
189
+ * colon are skipped; the first colon splits name from value.
190
+ */
191
+ export function parseHeaderLines(text: string | undefined): Record<string, string> {
192
+ const headers: Record<string, string> = {};
193
+ for (const line of (text ?? "").split(/\r?\n/)) {
194
+ const colon = line.indexOf(":");
195
+ if (colon <= 0) continue;
196
+ const name = line.slice(0, colon).trim();
197
+ const value = line.slice(colon + 1).trim();
198
+ if (name !== "") headers[name] = value;
199
+ }
200
+ return headers;
201
+ }
202
+
203
+ /**
204
+ * Does an env var NAME look secret-bearing? Keys, tokens, passwords,
205
+ * credentials — and header bundles, which carry a gateway's bearer token.
206
+ * Endpoint URLs deliberately do not match: they are diagnostic, not secret.
207
+ */
208
+ export function isSecretEnvName(name: string): boolean {
209
+ return /key|token|password|secret|credential|header/i.test(name);
210
+ }
211
+
212
+ /** Header names whose values are credentials (`cf-aig-authorization`, `x-api-key`, …). */
213
+ const SECRET_HEADER = /auth|key|token|secret|credential|password/i;
214
+
215
+ /**
216
+ * The redaction targets inside one header bundle (an env var holding `Name:
217
+ * Value` lines): each credential header's full value, plus the bare
218
+ * credential behind a `Bearer`/`Basic`/`Token` scheme — a transcript that
219
+ * prints only the token, not the whole bundle, must still be scrubbed. Keys
220
+ * name the bundle and header so the redaction marker says what was removed.
221
+ */
222
+ export function headerBundleSecrets(bundleName: string, bundle: string): Record<string, string> {
223
+ const derived: Record<string, string> = {};
224
+ for (const [header, value] of Object.entries(parseHeaderLines(bundle))) {
225
+ if (!SECRET_HEADER.test(header)) continue;
226
+ derived[`${bundleName}[${header}]`] = value;
227
+ const scheme = /^(?:Bearer|Basic|Token)\s+(\S+)$/i.exec(value);
228
+ if (scheme?.[1] !== undefined) derived[`${bundleName}[${header}]:credential`] = scheme[1];
229
+ }
230
+ return derived;
231
+ }
232
+
233
+ /** A secret env map plus the credentials carried inside any header bundle it holds. */
234
+ export function expandSecretEnv(env: Record<string, string>): Record<string, string> {
235
+ const expanded = { ...env };
236
+ for (const [name, value] of Object.entries(env)) {
237
+ if (/header/i.test(name)) Object.assign(expanded, headerBundleSecrets(name, value));
238
+ }
239
+ return expanded;
240
+ }
241
+
131
242
  /** Values shorter than this are never redacted (they would shred ordinary text). */
132
243
  const MIN_REDACTABLE_LENGTH = 6;
133
244
 
@@ -145,7 +256,9 @@ export function redactSecrets(text: string, env: Record<string, string>): string
145
256
  return redacted;
146
257
  }
147
258
 
148
- /** Provider/judge secret env only; ordinary PATH and workspace values are evidence. */
259
+ /** Provider/judge secret env only (header-bundle credentials included); ordinary PATH and workspace values are evidence. */
149
260
  export function hostSecretEnv(env: Record<string, string | undefined> = process.env): Record<string, string> {
150
- return Object.fromEntries(Object.entries(env).filter(([name, value]) => /key|token|password|secret|credential/i.test(name) && typeof value === "string")) as Record<string, string>;
261
+ return expandSecretEnv(
262
+ Object.fromEntries(Object.entries(env).filter(([name, value]) => isSecretEnvName(name) && typeof value === "string")) as Record<string, string>,
263
+ );
151
264
  }