@workos/quickstudy 0.8.1 → 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 +29 -1
- package/package.json +1 -1
- package/src/adapters/claude.ts +19 -3
- package/src/adapters/codex.ts +45 -4
- package/src/adapters/types.ts +21 -4
- package/src/build-info.generated.ts +1 -1
- package/src/execution-policy.ts +13 -0
- package/src/isolation/docker.ts +53 -26
- package/src/llm.ts +11 -3
- package/src/runner/execute.ts +34 -3
- package/src/secrets.ts +115 -2
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
|
|
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.
|
|
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",
|
package/src/adapters/claude.ts
CHANGED
|
@@ -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
|
|
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
|
|
70
|
-
headers: {
|
|
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
|
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
76
|
-
|
|
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[] {
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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.
|
|
12
|
+
version: "0.9.0", // x-release-please-version
|
|
13
13
|
revision: null,
|
|
14
14
|
dirty: null,
|
|
15
15
|
sourceHash: null,
|
package/src/execution-policy.ts
CHANGED
|
@@ -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;
|
package/src/isolation/docker.ts
CHANGED
|
@@ -59,15 +59,20 @@ export interface ExecResult {
|
|
|
59
59
|
timedOut: boolean;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
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(
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
51
|
-
*
|
|
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/runner/execute.ts
CHANGED
|
@@ -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,
|
|
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
|
|
261
|
+
return expandSecretEnv(
|
|
262
|
+
Object.fromEntries(Object.entries(env).filter(([name, value]) => isSecretEnvName(name) && typeof value === "string")) as Record<string, string>,
|
|
263
|
+
);
|
|
151
264
|
}
|