@workos/quickstudy 0.0.1

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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +270 -0
  3. package/examples/harbor-notes/README.md +40 -0
  4. package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
  5. package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
  6. package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
  7. package/examples/harbor-notes/experiments/scripted.ts +6 -0
  8. package/examples/harbor-notes/package.json +6 -0
  9. package/examples/harbor-notes/quickstudy.identity.json +1 -0
  10. package/examples/harbor-notes/runtime.ts +48 -0
  11. package/examples/harbor-notes/semantic-example.ts +21 -0
  12. package/images/agent-runtime/Dockerfile +58 -0
  13. package/images/egress-proxy/Dockerfile +28 -0
  14. package/images/mcp-proxy/Dockerfile +30 -0
  15. package/package.json +53 -0
  16. package/src/adapters/claude.ts +107 -0
  17. package/src/adapters/codex.ts +107 -0
  18. package/src/adapters/echo.ts +57 -0
  19. package/src/adapters/parse.ts +117 -0
  20. package/src/adapters/types.ts +152 -0
  21. package/src/build-info.generated.ts +12 -0
  22. package/src/cli.ts +787 -0
  23. package/src/completeness.ts +104 -0
  24. package/src/diagnose/excerpt.ts +106 -0
  25. package/src/diagnose/prompt.ts +175 -0
  26. package/src/diagnose/render.ts +55 -0
  27. package/src/diagnose/run.ts +290 -0
  28. package/src/diagnose/select.ts +110 -0
  29. package/src/diagnose/types.ts +88 -0
  30. package/src/evals/discovery.ts +173 -0
  31. package/src/evals/prompt.ts +190 -0
  32. package/src/evals/result.ts +10 -0
  33. package/src/evals/types.ts +115 -0
  34. package/src/execution-policy.ts +71 -0
  35. package/src/experiments/discovery.ts +76 -0
  36. package/src/experiments/groups.ts +119 -0
  37. package/src/experiments/types.ts +116 -0
  38. package/src/export-types.ts +127 -0
  39. package/src/export.ts +381 -0
  40. package/src/hash.ts +74 -0
  41. package/src/identity-diff.ts +30 -0
  42. package/src/ids.ts +30 -0
  43. package/src/index.ts +58 -0
  44. package/src/isolation/docker.ts +639 -0
  45. package/src/isolation/image-contexts.generated.ts +927 -0
  46. package/src/isolation/images.ts +138 -0
  47. package/src/isolation/mcp-proxy/server.ts +260 -0
  48. package/src/isolation/mcp.ts +144 -0
  49. package/src/isolation/proxy/allowlist.ts +148 -0
  50. package/src/isolation/proxy/server.ts +382 -0
  51. package/src/llm.ts +132 -0
  52. package/src/manifest.ts +228 -0
  53. package/src/model-identity.ts +12 -0
  54. package/src/plan.ts +55 -0
  55. package/src/probe.ts +426 -0
  56. package/src/report/pass-at-k.ts +76 -0
  57. package/src/report/report.ts +731 -0
  58. package/src/runner/context.ts +96 -0
  59. package/src/runner/deadline.ts +37 -0
  60. package/src/runner/execute.ts +992 -0
  61. package/src/runner/run-lock.ts +32 -0
  62. package/src/runner/scheduler.ts +62 -0
  63. package/src/runner/score-worker.ts +107 -0
  64. package/src/runner/scorer-worker.ts +61 -0
  65. package/src/runtime/types.ts +89 -0
  66. package/src/secrets.ts +151 -0
  67. package/src/semantic.ts +185 -0
  68. package/src/serve.ts +52 -0
  69. package/src/source-identity.ts +76 -0
  70. package/src/store/artifacts.ts +146 -0
  71. package/src/store/db.ts +318 -0
  72. package/src/store/schema.ts +39 -0
  73. package/src/surface-usage.ts +297 -0
  74. package/src/ui-bundle.generated.ts +12 -0
  75. package/ui/dist/index.html +32 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Claude Code adapter: drives the `claude` CLI headless inside the attempt
3
+ * container.
4
+ *
5
+ * `--dangerously-skip-permissions` is acceptable here ONLY because the
6
+ * container is the security boundary — a fresh filesystem, one write-only
7
+ * results mount, and force-removal after grading.
8
+ *
9
+ * Treatment differences are environmental, never prompt-level: the MCP
10
+ * treatment places `.mcp.json` into /workspace before start; the CLI
11
+ * treatment relies on the runtime-exposed PATH directory; the web treatment
12
+ * leaves native web tools enabled. The task prompt stays byte-identical
13
+ * across experiments.
14
+ */
15
+
16
+ import type { McpServerConfig } from "../isolation/mcp.ts";
17
+ import { parseClaudeStream } from "./parse.ts";
18
+ import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
19
+
20
+ /** Container path of the MCP config Claude Code auto-discovers. */
21
+ export const CLAUDE_MCP_CONFIG_PATH = "/workspace/.mcp.json";
22
+
23
+ /**
24
+ * Project-scope `.mcp.json` rendered from the runtime's MCP servers
25
+ * (streamable HTTP). The configs arrive credential-free — authed servers
26
+ * already point at the token-proxy sidecar — so only the URL is rendered.
27
+ */
28
+ function renderMcpJson(servers: Readonly<Record<string, McpServerConfig>> = {}): string {
29
+ const mcpServers = Object.fromEntries(
30
+ Object.entries(servers).map(([name, spec]) => [name, { type: "http", url: spec.url }]),
31
+ );
32
+ return `${JSON.stringify({ mcpServers }, null, 2)}\n`;
33
+ }
34
+
35
+ export class ClaudeCodeAdapter implements ContainerAgentAdapter {
36
+ readonly kind = "container" as const;
37
+ readonly name: string;
38
+ readonly identity;
39
+ readonly versionCommand = ["claude", "--version"] as const;
40
+ readonly requiredHostEnv = ["ANTHROPIC_API_KEY"] as const;
41
+ /**
42
+ * Egress-proxy allowlist (harness-default layer). Claude Code documents
43
+ * corporate-proxy support via HTTPS_PROXY/HTTP_PROXY; api.anthropic.com is
44
+ * the LLM endpoint, statsig/sentry are its telemetry (allowed so denial
45
+ * logs stay signal, not noise). Verified live by the egress-proxy regression
46
+ * check (`bun scripts/dev-attempt.ts --experiment <id> --egress-proxy`).
47
+ */
48
+ readonly egressHosts = ["api.anthropic.com", "statsig.anthropic.com", "*.sentry.io"] as const;
49
+
50
+ constructor(name = "claude", config: { model?: string; reasoning?: string } = {}) {
51
+ this.name = name;
52
+ this.identity = {
53
+ provider: "anthropic",
54
+ model: config.model ?? "UNPINNED",
55
+ reasoning: config.reasoning ?? "UNPINNED",
56
+ cliVersion: "unknown",
57
+ surfacePolicies: {
58
+ docs: "native WebSearch/WebFetch allowed",
59
+ mcp: "--disallowedTools WebSearch,WebFetch; MCP configured",
60
+ cli: "--disallowedTools WebSearch,WebFetch",
61
+ },
62
+ nativeWebControl: "controlled" as const,
63
+ reproducible: true,
64
+ };
65
+ }
66
+
67
+ keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
68
+ return {
69
+ url: "https://api.anthropic.com/v1/models",
70
+ headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "anthropic-version": "2023-06-01" },
71
+ };
72
+ }
73
+
74
+ setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
75
+ if (ctx.mcpServers === undefined) return {};
76
+ return { [CLAUDE_MCP_CONFIG_PATH]: renderMcpJson(ctx.mcpServers) };
77
+ }
78
+
79
+ command(ctx: ContainerAttemptContext): string[] {
80
+ const args = [
81
+ "claude",
82
+ "-p",
83
+ ctx.prompt,
84
+ // stream-json is the parseable telemetry source; it requires --verbose
85
+ // in print mode.
86
+ "--output-format",
87
+ "stream-json",
88
+ "--verbose",
89
+ "--dangerously-skip-permissions",
90
+ "--model",
91
+ this.identity.model,
92
+ "--effort",
93
+ this.identity.reasoning,
94
+ ];
95
+ if (ctx.mcpServers !== undefined) {
96
+ // Explicit config beats auto-discovery: headless runs must not depend
97
+ // on project-trust prompts.
98
+ args.push("--mcp-config", CLAUDE_MCP_CONFIG_PATH);
99
+ }
100
+ if (ctx.webPolicy !== "native-web-allowed") args.push("--disallowedTools", "WebSearch,WebFetch");
101
+ return args;
102
+ }
103
+
104
+ parseStream(raw: string): AttemptMetrics {
105
+ return parseClaudeStream(raw);
106
+ }
107
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Codex CLI adapter: drives `codex exec --json` headless inside the attempt
3
+ * container.
4
+ *
5
+ * Sandbox/approval bypass is acceptable ONLY because the container is the
6
+ * security boundary (same rationale as the Claude adapter). MCP treatment
7
+ * config lives in `~/.codex/config.toml` in-container — Codex has no
8
+ * per-project MCP file. Auth: the OPENAI_API_KEY env var is injected at
9
+ * container create, but codex >= 0.144 no longer reads it directly — the
10
+ * command wrapper pipes it into `codex login --with-api-key` (stdin, never
11
+ * argv) to write the auth.json the CLI requires.
12
+ */
13
+
14
+ import type { McpServerConfig } from "../isolation/mcp.ts";
15
+ import { parseCodexStream } from "./parse.ts";
16
+ import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
17
+
18
+ /** Container path of the Codex config (containers run as root). */
19
+ export const CODEX_CONFIG_PATH = "/root/.codex/config.toml";
20
+
21
+ /**
22
+ * `config.toml` MCP table rendered from the runtime's MCP servers (url-based,
23
+ * credential-free — authed servers already point at the token-proxy sidecar).
24
+ * Server names outside TOML's bare-key charset get quoted.
25
+ */
26
+ function renderCodexMcpToml(servers: Readonly<Record<string, McpServerConfig>> = {}): string {
27
+ const blocks = Object.entries(servers).map(([name, spec]) => {
28
+ const key = /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
29
+ return `[mcp_servers.${key}]\nurl = ${JSON.stringify(spec.url)}\n`;
30
+ });
31
+ return blocks.length > 0 ? blocks.join("\n") : "[mcp_servers]\n";
32
+ }
33
+
34
+ export class CodexAdapter implements ContainerAgentAdapter {
35
+ readonly kind = "container" as const;
36
+ readonly name: string;
37
+ readonly identity;
38
+ readonly versionCommand = ["codex", "--version"] as const;
39
+ readonly requiredHostEnv = ["OPENAI_API_KEY"] as const;
40
+ /**
41
+ * Egress-proxy allowlist (harness-default layer). Codex is reqwest-based;
42
+ * reqwest honors HTTP_PROXY/HTTPS_PROXY env by default (system proxies) —
43
+ * verified live by the egress-proxy regression check (the CONNECT tunnel
44
+ * to api.openai.com round-tripped). chatgpt.com came from that run's
45
+ * denial log: codex probes its ChatGPT-auth endpoints even under key auth.
46
+ * (Its github.com update-check denials are left denied on purpose.)
47
+ */
48
+ readonly egressHosts = ["api.openai.com", "auth.openai.com", "chatgpt.com", "*.chatgpt.com"] as const;
49
+
50
+ constructor(name = "codex", config: { model?: string; reasoning?: string } = {}) {
51
+ this.name = name;
52
+ this.identity = {
53
+ provider: "openai",
54
+ model: config.model ?? "UNPINNED",
55
+ reasoning: config.reasoning ?? "UNPINNED",
56
+ cliVersion: "unknown",
57
+ surfacePolicies: {
58
+ docs: 'web_search="live"',
59
+ mcp: 'web_search="disabled"; MCP configured',
60
+ cli: 'web_search="disabled"',
61
+ },
62
+ nativeWebControl: "controlled" as const,
63
+ reproducible: true,
64
+ };
65
+ }
66
+
67
+ keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
68
+ return {
69
+ url: "https://api.openai.com/v1/models",
70
+ headers: { Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
71
+ };
72
+ }
73
+
74
+ setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
75
+ if (ctx.mcpServers === undefined) return {};
76
+ return { [CODEX_CONFIG_PATH]: renderCodexMcpToml(ctx.mcpServers) };
77
+ }
78
+
79
+ command(ctx: ContainerAttemptContext): string[] {
80
+ return [
81
+ "sh",
82
+ "-c",
83
+ // codex 0.144+ ignores the OPENAI_API_KEY env var; mint auth.json from
84
+ // it in-container. Login chatter is silenced so stdout stays pure JSONL
85
+ // for parseStream; the key reaches login via stdin, never argv.
86
+ 'printenv OPENAI_API_KEY | codex login --with-api-key >/dev/null 2>&1 && exec "$@"',
87
+ "sh",
88
+ "codex",
89
+ "exec",
90
+ "--json",
91
+ "--model",
92
+ this.identity.model,
93
+ "--config",
94
+ `model_reasoning_effort=${JSON.stringify(this.identity.reasoning)}`,
95
+ "--config",
96
+ `web_search=${JSON.stringify(ctx.webPolicy === "native-web-allowed" ? "live" : "disabled")}`,
97
+ // The container is the boundary; codex's own sandbox would fight the
98
+ // fixture's build tooling for no additional isolation.
99
+ "--dangerously-bypass-approvals-and-sandbox",
100
+ ctx.prompt,
101
+ ];
102
+ }
103
+
104
+ parseStream(raw: string): AttemptMetrics {
105
+ return parseCodexStream(raw);
106
+ }
107
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The echo adapter: a no-op agent for pipeline testing.
3
+ *
4
+ * It copies the fixture into the workspace, writes a canned transcript, and
5
+ * makes exactly one trivial file edit (NOTES.md). This proves the full
6
+ * run -> grade -> persist pipeline end-to-end without Docker or LLM spend,
7
+ * and gives every later phase a fast integration-test target.
8
+ */
9
+
10
+ import { cp, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import type { AgentAdapter, AttemptContext, AttemptOutcome, TranscriptEvent } from "./types.ts";
13
+
14
+ export class EchoAdapter implements AgentAdapter {
15
+ readonly name: string;
16
+ readonly identity = {
17
+ provider: "local", model: "echo-v1", reasoning: "none", cliVersion: "built-in",
18
+ surfacePolicies: { docs: "no-network", mcp: "no-network", cli: "no-network" },
19
+ nativeWebControl: "controlled" as const,
20
+ reproducible: true,
21
+ };
22
+
23
+ constructor(name = "echo") {
24
+ this.name = name;
25
+ }
26
+
27
+ async runAttempt(ctx: AttemptContext): Promise<AttemptOutcome> {
28
+ await cp(ctx.fixtureDir, ctx.workspaceDir, { recursive: true });
29
+
30
+ const notes =
31
+ `# Notes\n\n` +
32
+ `Echo adapter placeholder: no-op pipeline smoke for attempt ${ctx.attemptId} ` +
33
+ `(trial ${ctx.trialIndex}).\n`;
34
+ await writeFile(join(ctx.workspaceDir, "NOTES.md"), notes, "utf8");
35
+
36
+ const transcript: TranscriptEvent[] = [
37
+ { role: "user", content: ctx.prompt, at: new Date().toISOString() },
38
+ {
39
+ role: "assistant",
40
+ content: `Copied the fixture and wrote NOTES.md (echo pipeline smoke).`,
41
+ at: new Date().toISOString(),
42
+ },
43
+ ];
44
+
45
+ const noteLines = notes.trimEnd().split("\n");
46
+ const diff =
47
+ `--- /dev/null\n+++ b/NOTES.md\n@@ -0,0 +1,${noteLines.length} @@\n` +
48
+ noteLines.map((line) => `+${line}`).join("\n") +
49
+ "\n";
50
+
51
+ return {
52
+ transcript,
53
+ diff,
54
+ metrics: { tokensIn: ctx.prompt.length, tokensOut: notes.length, turns: 1, costUsd: 0 },
55
+ };
56
+ }
57
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared parsers for the agents' headless output streams, reducing each raw
3
+ * JSONL stream to telemetry: {tokensIn, tokensOut, turns, costUsd}.
4
+ *
5
+ * Tolerance is the design constraint: vendor CLIs drift between releases
6
+ * (versions are pinned in images/agent-runtime/Dockerfile, and these parsers
7
+ * are tested against checked-in fixture streams), so a malformed or
8
+ * unrecognized stream must degrade gracefully — never a crash, and never a
9
+ * lost transcript (the raw stream is persisted before parsing).
10
+ *
11
+ * Degradation records NULL token counts, not zeros: a stream that carried no
12
+ * usage data is "telemetry unavailable", and zeros would poison cost stats
13
+ * (the store's telemetry columns are nullable for exactly this). Turns stay
14
+ * numeric — visible turns are countable even from a truncated stream.
15
+ */
16
+
17
+ import type { AttemptMetrics } from "./types.ts";
18
+
19
+ /** Parse a JSONL stream leniently: non-JSON lines are skipped, never fatal. */
20
+ export function parseJsonlLines(raw: string): unknown[] {
21
+ const objects: unknown[] = [];
22
+ for (const line of raw.split("\n")) {
23
+ const trimmed = line.trim();
24
+ if (trimmed === "" || !trimmed.startsWith("{")) continue;
25
+ try {
26
+ objects.push(JSON.parse(trimmed));
27
+ } catch {
28
+ // Tolerated: agents interleave non-JSON noise (progress bars, warnings).
29
+ }
30
+ }
31
+ return objects;
32
+ }
33
+
34
+ function num(value: unknown): number {
35
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
36
+ }
37
+
38
+ /** A token field that is absent is "unavailable" (null) — never coerced to 0. */
39
+ function numOrNull(value: unknown): number | null {
40
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
41
+ }
42
+
43
+ function record(value: unknown): Record<string, unknown> {
44
+ return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
45
+ }
46
+
47
+ /**
48
+ * Claude Code `--output-format stream-json`: one JSON event per line, ending
49
+ * (on success) with a `type: "result"` event that carries authoritative
50
+ * usage, num_turns, and total_cost_usd. Cache reads/writes count as input —
51
+ * they are billed tokens the attempt consumed. A stream that died before its
52
+ * result event has no usage data: token counts degrade to null.
53
+ */
54
+ export function parseClaudeStream(raw: string): AttemptMetrics {
55
+ const metrics: AttemptMetrics = { tokensIn: null, tokensOut: null, turns: 0 };
56
+ let assistantEvents = 0;
57
+ for (const event of parseJsonlLines(raw)) {
58
+ const obj = record(event);
59
+ if (obj["type"] === "assistant") assistantEvents += 1;
60
+ if (obj["type"] !== "result") continue;
61
+ const usage = record(obj["usage"]);
62
+ metrics.tokensIn =
63
+ num(usage["input_tokens"]) + num(usage["cache_creation_input_tokens"]) + num(usage["cache_read_input_tokens"]);
64
+ metrics.tokensOut = num(usage["output_tokens"]);
65
+ metrics.turns = num(obj["num_turns"]);
66
+ const cost = obj["total_cost_usd"];
67
+ if (typeof cost === "number" && Number.isFinite(cost)) metrics.costUsd = cost;
68
+ }
69
+ // A stream that died before its result event still counts visible turns.
70
+ if (metrics.turns === 0) metrics.turns = assistantEvents;
71
+ return metrics;
72
+ }
73
+
74
+ /**
75
+ * Codex `exec --json`: one JSON event per line. Two shapes exist across
76
+ * pinned-version history, and both are handled:
77
+ * - thread events: `{"type":"turn.completed","usage":{...}}` per turn;
78
+ * - legacy protocol events: `{"msg":{"type":"token_count",...}}` with
79
+ * cumulative totals (last one wins) and `agent_message` turns.
80
+ *
81
+ * Unlike Anthropic usage (where cache tokens are separate counters), OpenAI
82
+ * usage reports `input_tokens` as the total with cached tokens as a SUBSET —
83
+ * adding `cached_input_tokens` on top would double-count. A stream with no
84
+ * usage events at all degrades to null token counts.
85
+ */
86
+ export function parseCodexStream(raw: string): AttemptMetrics {
87
+ const metrics: AttemptMetrics = { tokensIn: null, tokensOut: null, turns: 0 };
88
+ let legacyTurns = 0;
89
+ for (const event of parseJsonlLines(raw)) {
90
+ const obj = record(event);
91
+
92
+ if (obj["type"] === "turn.completed") {
93
+ const usage = record(obj["usage"]);
94
+ const input = numOrNull(usage["input_tokens"]);
95
+ const output = numOrNull(usage["output_tokens"]);
96
+ if (input !== null) metrics.tokensIn = (metrics.tokensIn ?? 0) + input;
97
+ if (output !== null) metrics.tokensOut = (metrics.tokensOut ?? 0) + output;
98
+ metrics.turns += 1;
99
+ continue;
100
+ }
101
+
102
+ const msg = record(obj["msg"]);
103
+ if (msg["type"] === "agent_message") legacyTurns += 1;
104
+ if (msg["type"] === "token_count") {
105
+ // Cumulative totals: the last token_count event is authoritative.
106
+ const info = record(msg["info"]);
107
+ const total = record(info["total_token_usage"]);
108
+ const tokensIn = num(total["input_tokens"]) || num(msg["input_tokens"]);
109
+ const tokensOut = num(total["output_tokens"]) || num(msg["output_tokens"]);
110
+ if (tokensIn > 0) metrics.tokensIn = tokensIn;
111
+ if (tokensOut > 0) metrics.tokensOut = tokensOut;
112
+ }
113
+ }
114
+ if (metrics.turns === 0) metrics.turns = legacyTurns;
115
+ return metrics;
116
+ }
117
+
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The agent adapter contract.
3
+ *
4
+ * The v3 runner depends only on these interfaces: it prepares the attempt's
5
+ * starting state from the eval's `local/`, hands the adapter the eval's
6
+ * prompt plus the experiment's treatment context, and expects back a
7
+ * transcript, a diff, and metrics. Host adapters run in a scratch directory
8
+ * (the echo pipeline smoke); container adapters compose a headless CLI
9
+ * invocation the runner executes inside the attempt container.
10
+ */
11
+
12
+ import type { McpServerConfig } from "../isolation/mcp.ts";
13
+ import type { WebPolicy } from "../runtime/types.ts";
14
+
15
+ /** Immutable agent configuration recorded in every experiment manifest. */
16
+ export interface AdapterIdentityConfig {
17
+ provider: string;
18
+ model: string;
19
+ reasoning: string;
20
+ /** Filled from the executable in the selected image when possible. */
21
+ cliVersion: string;
22
+ surfacePolicies: Record<string, string>;
23
+ /** Whether non-web treatments can disable native/vendor web tools. This is not network isolation. */
24
+ nativeWebControl: "controlled" | "uncontrolled";
25
+ reproducible: boolean;
26
+ }
27
+
28
+ /** One event in an attempt's transcript (persisted as JSONL). */
29
+ export interface TranscriptEvent {
30
+ role: "system" | "user" | "assistant" | "tool";
31
+ content: string;
32
+ /** ISO-8601 timestamp. */
33
+ at: string;
34
+ }
35
+
36
+ export interface AttemptMetrics {
37
+ /**
38
+ * `null` means telemetry unavailable — the vendor stream carried no usage
39
+ * data (or died before it). Never conflate with 0: zeros poison cost
40
+ * comparisons, nulls render as "unavailable". The store's telemetry
41
+ * columns have been nullable from the start for exactly this case.
42
+ */
43
+ tokensIn: number | null;
44
+ tokensOut: number | null;
45
+ turns: number;
46
+ costUsd?: number;
47
+ }
48
+
49
+ /** Everything a host adapter gets for one attempt. */
50
+ export interface AttemptContext {
51
+ /** Aborted when the current stage expires. Implementations must stop work. */
52
+ signal?: AbortSignal;
53
+ runId: string;
54
+ attemptId: string;
55
+ /** Absolute path to the directory holding the attempt's starting state. */
56
+ fixtureDir: string;
57
+ /**
58
+ * Absolute path to an empty scratch directory. The adapter populates it
59
+ * from the fixture and performs its edits there; the runner exports
60
+ * it into the artifacts store afterwards.
61
+ */
62
+ workspaceDir: string;
63
+ /** The eval's task prompt, byte-identical across experiments. */
64
+ prompt: string;
65
+ trialIndex: number;
66
+ }
67
+
68
+ /** What an adapter returns for a completed attempt. */
69
+ export interface AttemptOutcome {
70
+ transcript: TranscriptEvent[];
71
+ /** Unified diff of the workspace against the fixture. */
72
+ diff: string;
73
+ metrics: AttemptMetrics;
74
+ }
75
+
76
+ export interface AgentAdapter {
77
+ /** The adapter name experiments select via `agent.adapter` (e.g. "echo"). */
78
+ name: string;
79
+ identity?: AdapterIdentityConfig;
80
+ runAttempt(ctx: AttemptContext): Promise<AttemptOutcome>;
81
+ }
82
+
83
+ /**
84
+ * What a container adapter gets when composing its invocation. Host paths are
85
+ * deliberately absent — a container adapter never touches the host
86
+ * filesystem; the runner owns the container lifecycle so scoring can reach
87
+ * the live sandbox before teardown.
88
+ */
89
+ export interface ContainerAttemptContext {
90
+ runId: string;
91
+ attemptId: string;
92
+ /** The eval's task prompt, byte-identical across experiments. */
93
+ prompt: string;
94
+ trialIndex: number;
95
+ /**
96
+ * The experiment runtime's MCP servers, credential-free (authed entries
97
+ * already point at the token-proxy sidecar) — present only when the
98
+ * runtime declares servers. The adapter renders these into its vendor's
99
+ * config format via setupFiles.
100
+ */
101
+ mcpServers?: Readonly<Record<string, McpServerConfig>>;
102
+ /** The runtime's native web-tool policy (default: blocked). */
103
+ webPolicy: WebPolicy;
104
+ }
105
+
106
+ /**
107
+ * A real agent driven headless inside the attempt container. The adapter is
108
+ * pure composition + parsing: which files to place (treatment config),
109
+ * which command to run in /workspace, which host env vars must exist, and
110
+ * how to turn the raw output stream into telemetry. The raw stream itself is
111
+ * the transcript artifact — parsing failures can never lose evidence.
112
+ */
113
+ export interface ContainerAgentAdapter {
114
+ kind: "container";
115
+ /** The adapter name experiments select via `agent.adapter` (e.g. "claude", "codex"). */
116
+ name: string;
117
+ identity?: AdapterIdentityConfig;
118
+ /** Command used during preflight to record the executable's exact version. */
119
+ versionCommand?: readonly string[];
120
+ /**
121
+ * Cheap authenticated request composed from the collected provider keys,
122
+ * probed at run preflight so a present-but-rejected key aborts BEFORE any
123
+ * container spend. A 401/403 response stops the run; network failures only
124
+ * warn — an unreachable probe must not gate a run the agent might complete.
125
+ */
126
+ keyProbe?(env: Record<string, string>): { url: string; headers: Record<string, string> };
127
+ /**
128
+ * Host env vars that must be present before any container is created
129
+ * (shared provider keys — see src/secrets.ts).
130
+ */
131
+ requiredHostEnv: readonly string[];
132
+ /**
133
+ * Hosts this agent's CLI must reach (its LLM/vendor endpoints) — the
134
+ * harness-default layer of the egress allowlist when the run uses the
135
+ * egress proxy (see isolation/proxy/allowlist.ts). Adapters that omit it
136
+ * contribute nothing, which under the proxy means the agent cannot reach
137
+ * any endpoint the runtime's own egress hosts don't list.
138
+ */
139
+ egressHosts?: readonly string[];
140
+ /**
141
+ * Files to write inside the container before the agent starts, keyed by
142
+ * absolute container path. This is where treatments differ: the MCP
143
+ * treatment places MCP config, others place nothing.
144
+ */
145
+ setupFiles(ctx: ContainerAttemptContext): Record<string, string>;
146
+ /** The headless invocation, executed in /workspace inside the container. */
147
+ command(ctx: ContainerAttemptContext): string[];
148
+ /** Parse the raw output stream into telemetry. Must tolerate malformed streams. */
149
+ parseStream(raw: string): AttemptMetrics;
150
+ }
151
+
152
+ export type AnyAgentAdapter = AgentAdapter | ContainerAgentAdapter;
@@ -0,0 +1,12 @@
1
+ /** Populated temporarily by scripts/build-binary.ts for compiled releases. */
2
+ export const EMBEDDED_BUILD_INFO: {
3
+ version: string;
4
+ revision: string | null;
5
+ dirty: boolean | null;
6
+ sourceHash: string | null;
7
+ } = {
8
+ version: "0.1.0",
9
+ revision: null,
10
+ dirty: null,
11
+ sourceHash: null,
12
+ };