@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.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/examples/harbor-notes/README.md +40 -0
- package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
- package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
- package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
- package/examples/harbor-notes/experiments/scripted.ts +6 -0
- package/examples/harbor-notes/package.json +6 -0
- package/examples/harbor-notes/quickstudy.identity.json +1 -0
- package/examples/harbor-notes/runtime.ts +48 -0
- package/examples/harbor-notes/semantic-example.ts +21 -0
- package/images/agent-runtime/Dockerfile +58 -0
- package/images/egress-proxy/Dockerfile +28 -0
- package/images/mcp-proxy/Dockerfile +30 -0
- package/package.json +53 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +107 -0
- package/src/adapters/echo.ts +57 -0
- package/src/adapters/parse.ts +117 -0
- package/src/adapters/types.ts +152 -0
- package/src/build-info.generated.ts +12 -0
- package/src/cli.ts +787 -0
- package/src/completeness.ts +104 -0
- package/src/diagnose/excerpt.ts +106 -0
- package/src/diagnose/prompt.ts +175 -0
- package/src/diagnose/render.ts +55 -0
- package/src/diagnose/run.ts +290 -0
- package/src/diagnose/select.ts +110 -0
- package/src/diagnose/types.ts +88 -0
- package/src/evals/discovery.ts +173 -0
- package/src/evals/prompt.ts +190 -0
- package/src/evals/result.ts +10 -0
- package/src/evals/types.ts +115 -0
- package/src/execution-policy.ts +71 -0
- package/src/experiments/discovery.ts +76 -0
- package/src/experiments/groups.ts +119 -0
- package/src/experiments/types.ts +116 -0
- package/src/export-types.ts +127 -0
- package/src/export.ts +381 -0
- package/src/hash.ts +74 -0
- package/src/identity-diff.ts +30 -0
- package/src/ids.ts +30 -0
- package/src/index.ts +58 -0
- package/src/isolation/docker.ts +639 -0
- package/src/isolation/image-contexts.generated.ts +927 -0
- package/src/isolation/images.ts +138 -0
- package/src/isolation/mcp-proxy/server.ts +260 -0
- package/src/isolation/mcp.ts +144 -0
- package/src/isolation/proxy/allowlist.ts +148 -0
- package/src/isolation/proxy/server.ts +382 -0
- package/src/llm.ts +132 -0
- package/src/manifest.ts +228 -0
- package/src/model-identity.ts +12 -0
- package/src/plan.ts +55 -0
- package/src/probe.ts +426 -0
- package/src/report/pass-at-k.ts +76 -0
- package/src/report/report.ts +731 -0
- package/src/runner/context.ts +96 -0
- package/src/runner/deadline.ts +37 -0
- package/src/runner/execute.ts +992 -0
- package/src/runner/run-lock.ts +32 -0
- package/src/runner/scheduler.ts +62 -0
- package/src/runner/score-worker.ts +107 -0
- package/src/runner/scorer-worker.ts +61 -0
- package/src/runtime/types.ts +89 -0
- package/src/secrets.ts +151 -0
- package/src/semantic.ts +185 -0
- package/src/serve.ts +52 -0
- package/src/source-identity.ts +76 -0
- package/src/store/artifacts.ts +146 -0
- package/src/store/db.ts +318 -0
- package/src/store/schema.ts +39 -0
- package/src/surface-usage.ts +297 -0
- package/src/ui-bundle.generated.ts +12 -0
- package/ui/dist/index.html +32 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdirSync, openSync, readFileSync, closeSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Refuse simultaneous writers; recover a lock only when its process is gone. */
|
|
4
|
+
export function acquireRunLock(root: string, runId: string): () => void {
|
|
5
|
+
const dir = join(root, runId);
|
|
6
|
+
mkdirSync(dir, { recursive: true });
|
|
7
|
+
const path = join(dir, "runner.lock");
|
|
8
|
+
const create = () => {
|
|
9
|
+
const fd = openSync(path, "wx", 0o600);
|
|
10
|
+
writeFileSync(fd, String(process.pid));
|
|
11
|
+
closeSync(fd);
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
create();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
17
|
+
const pid = Number(readFileSync(path, "utf8"));
|
|
18
|
+
if (!Number.isInteger(pid) || pid < 1) throw new Error(`invalid run lock for ${runId}`);
|
|
19
|
+
let alive = true;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") alive = false;
|
|
24
|
+
}
|
|
25
|
+
if (alive) throw new Error(`run ${runId} is already active (pid ${pid})`);
|
|
26
|
+
unlinkSync(path);
|
|
27
|
+
create();
|
|
28
|
+
}
|
|
29
|
+
return () => {
|
|
30
|
+
unlinkSync(path);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { hashString } from "../hash.ts";
|
|
2
|
+
import type { AttemptPlan } from "../plan.ts";
|
|
3
|
+
|
|
4
|
+
/** Seeded order changes dispatch timing, never trial coordinates. */
|
|
5
|
+
export function orderedPlan(plan: AttemptPlan[], seed: string): AttemptPlan[] {
|
|
6
|
+
return [...plan].sort((a, b) =>
|
|
7
|
+
hashString(`${seed}:${JSON.stringify(a)}`).localeCompare(hashString(`${seed}:${JSON.stringify(b)}`)),
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
export async function schedule<T>(
|
|
11
|
+
items: T[],
|
|
12
|
+
concurrency: number,
|
|
13
|
+
work: (item: T) => Promise<void>,
|
|
14
|
+
options: {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
capacity?: (item: T) => { key: string; limit: number } | undefined;
|
|
17
|
+
} = {},
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("concurrency must be a positive integer");
|
|
20
|
+
const capacities = new Map<T, { key: string; limit: number } | undefined>();
|
|
21
|
+
const limits = new Map<string, number>();
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
const cap = options.capacity?.(item);
|
|
24
|
+
if (cap) {
|
|
25
|
+
if (!cap.key || !Number.isInteger(cap.limit) || cap.limit < 1)
|
|
26
|
+
throw new Error("runtime capacity must have a key and positive integer limit");
|
|
27
|
+
if (limits.has(cap.key) && limits.get(cap.key) !== cap.limit)
|
|
28
|
+
throw new Error(`conflicting runtime capacity for ${cap.key}`);
|
|
29
|
+
limits.set(cap.key, cap.limit);
|
|
30
|
+
}
|
|
31
|
+
capacities.set(item, cap);
|
|
32
|
+
}
|
|
33
|
+
const remaining = [...items];
|
|
34
|
+
const running = new Set<Promise<void>>();
|
|
35
|
+
const active = new Map<string, number>();
|
|
36
|
+
let failure: unknown;
|
|
37
|
+
while (remaining.length || running.size) {
|
|
38
|
+
while (!failure && !options.signal?.aborted && running.size < concurrency) {
|
|
39
|
+
const index = remaining.findIndex((item) => {
|
|
40
|
+
const cap = capacities.get(item);
|
|
41
|
+
return !cap || (active.get(cap.key) ?? 0) < cap.limit;
|
|
42
|
+
});
|
|
43
|
+
if (index < 0) break;
|
|
44
|
+
const item = remaining.splice(index, 1)[0]!;
|
|
45
|
+
const cap = capacities.get(item);
|
|
46
|
+
if (cap) active.set(cap.key, (active.get(cap.key) ?? 0) + 1);
|
|
47
|
+
const task = Promise.resolve()
|
|
48
|
+
.then(() => work(item))
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
failure = err;
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
running.delete(task);
|
|
54
|
+
if (cap) active.set(cap.key, (active.get(cap.key) ?? 1) - 1);
|
|
55
|
+
});
|
|
56
|
+
running.add(task);
|
|
57
|
+
}
|
|
58
|
+
if (!running.size) break;
|
|
59
|
+
await Promise.race(running);
|
|
60
|
+
}
|
|
61
|
+
if (failure) throw failure;
|
|
62
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
import type { EvalContext, EvalResult } from "../evals/types.ts";
|
|
3
|
+
|
|
4
|
+
/** Scorers run in a terminable worker: a deadline also stops late direct file mutations. */
|
|
5
|
+
export async function scoreInWorker(
|
|
6
|
+
scorerPath: string,
|
|
7
|
+
context: EvalContext,
|
|
8
|
+
signal: AbortSignal,
|
|
9
|
+
): Promise<EvalResult> {
|
|
10
|
+
signal.throwIfAborted();
|
|
11
|
+
const worker = new Worker(
|
|
12
|
+
new URL(import.meta.url.includes("/$bunfs/") ? "./runner/scorer-worker.js" : "./scorer-worker.ts", import.meta.url),
|
|
13
|
+
);
|
|
14
|
+
const methods: Array<(...args: any[]) => unknown> = [];
|
|
15
|
+
function serialize(value: any, depth = 0): any {
|
|
16
|
+
if (depth > 8) throw new Error("client capability exceeds serialization depth");
|
|
17
|
+
if (typeof value === "function") {
|
|
18
|
+
const id = methods.length;
|
|
19
|
+
methods.push(value);
|
|
20
|
+
return { __quickstudyMethod: id };
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) return value.map((v) => serialize(v, depth + 1));
|
|
23
|
+
if (value && typeof value === "object")
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.entries(value).map(([key, item]) => [
|
|
26
|
+
key,
|
|
27
|
+
serialize(typeof item === "function" ? item.bind(value) : item, depth + 1),
|
|
28
|
+
]),
|
|
29
|
+
);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
let settled = false;
|
|
33
|
+
try {
|
|
34
|
+
return await new Promise<EvalResult>((resolve, reject) => {
|
|
35
|
+
const abort = () => {
|
|
36
|
+
void worker.terminate();
|
|
37
|
+
reject(signal.reason);
|
|
38
|
+
};
|
|
39
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
40
|
+
worker.once("exit", () => {
|
|
41
|
+
signal.removeEventListener("abort", abort);
|
|
42
|
+
reject(new Error("scorer worker exited before returning a result"));
|
|
43
|
+
});
|
|
44
|
+
worker.once("error", reject);
|
|
45
|
+
worker.on("message", async (message) => {
|
|
46
|
+
if (message.type === "result") {
|
|
47
|
+
settled = true;
|
|
48
|
+
resolve(message.result);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (message.type === "failure") {
|
|
52
|
+
settled = true;
|
|
53
|
+
reject(Object.assign(new Error(message.error), { judgments: message.judgments }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let response: { value?: unknown; error?: string };
|
|
57
|
+
try {
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
const [first, second] = message.args;
|
|
60
|
+
const value =
|
|
61
|
+
message.method === "exec"
|
|
62
|
+
? await context.exec(first, second)
|
|
63
|
+
: message.method === "query"
|
|
64
|
+
? await context.query(first)
|
|
65
|
+
: message.method === "redact"
|
|
66
|
+
? ((await context.redact?.(first)) ?? first)
|
|
67
|
+
: message.method === "getClient"
|
|
68
|
+
? context.getClient(first)
|
|
69
|
+
: message.method === "clientMethod" && methods[first]
|
|
70
|
+
? await methods[first]!(...second)
|
|
71
|
+
: (() => {
|
|
72
|
+
throw new Error("unsupported scorer capability");
|
|
73
|
+
})();
|
|
74
|
+
response = { value: serialize(value) };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
response = { error: error instanceof Error ? error.message : String(error) };
|
|
77
|
+
}
|
|
78
|
+
if (settled || signal.aborted) return;
|
|
79
|
+
if (message.type === "sync") {
|
|
80
|
+
const state = new Int32Array(message.buffer, 0, 2);
|
|
81
|
+
let encoded = new TextEncoder().encode(JSON.stringify(response));
|
|
82
|
+
if (encoded.length > message.buffer.byteLength - 8)
|
|
83
|
+
encoded = new TextEncoder().encode('{"error":"client capability exceeds 1 MiB"}');
|
|
84
|
+
new Uint8Array(message.buffer, 8, encoded.length).set(encoded);
|
|
85
|
+
state[1] = encoded.length;
|
|
86
|
+
Atomics.store(state, 0, 1);
|
|
87
|
+
Atomics.notify(state, 0);
|
|
88
|
+
} else if (!signal.aborted) worker.postMessage({ type: "reply", id: message.id, ...response });
|
|
89
|
+
});
|
|
90
|
+
worker.postMessage({
|
|
91
|
+
type: "start",
|
|
92
|
+
scorerPath,
|
|
93
|
+
context: {
|
|
94
|
+
evalId: context.evalId,
|
|
95
|
+
experimentId: context.experimentId,
|
|
96
|
+
trialIndex: context.trialIndex,
|
|
97
|
+
framework: context.framework,
|
|
98
|
+
workspaceDir: context.workspaceDir,
|
|
99
|
+
agentOutput: context.agentOutput,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
} finally {
|
|
104
|
+
settled = true;
|
|
105
|
+
await worker.terminate();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { importEvalScorer } from "../evals/discovery.ts";
|
|
3
|
+
import { buildEvalContext } from "./context.ts";
|
|
4
|
+
|
|
5
|
+
if (!parentPort) throw new Error("scorer worker requires a parent");
|
|
6
|
+
const port = parentPort;
|
|
7
|
+
let sequence = 0;
|
|
8
|
+
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
|
9
|
+
function revive(value: any): any {
|
|
10
|
+
if (value && typeof value === "object") {
|
|
11
|
+
if (typeof value.__quickstudyMethod === "number")
|
|
12
|
+
return (...args: unknown[]) => rpc("clientMethod", [value.__quickstudyMethod, args]);
|
|
13
|
+
if (Array.isArray(value)) return value.map(revive);
|
|
14
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, revive(item)]));
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function rpc(method: string, args: unknown[]): Promise<any> {
|
|
19
|
+
const id = sequence++;
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
pending.set(id, { resolve, reject });
|
|
22
|
+
port.postMessage({ type: "rpc", id, method, args });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function getClient(name: string): unknown {
|
|
26
|
+
const buffer = new SharedArrayBuffer(1024 * 1024);
|
|
27
|
+
const state = new Int32Array(buffer, 0, 2);
|
|
28
|
+
port.postMessage({ type: "sync", method: "getClient", args: [name], buffer });
|
|
29
|
+
Atomics.wait(state, 0, 0);
|
|
30
|
+
const result = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 8, state[1]!)));
|
|
31
|
+
if (result.error) throw new Error(result.error);
|
|
32
|
+
return revive(result.value);
|
|
33
|
+
}
|
|
34
|
+
port.on("message", async (message) => {
|
|
35
|
+
if (message.type === "reply") {
|
|
36
|
+
const waiter = pending.get(message.id);
|
|
37
|
+
pending.delete(message.id);
|
|
38
|
+
if (message.error) waiter?.reject(new Error(message.error));
|
|
39
|
+
else waiter?.resolve(revive(message.value));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (message.type !== "start") return;
|
|
43
|
+
try {
|
|
44
|
+
const scorer = await importEvalScorer(message.scorerPath);
|
|
45
|
+
const context = buildEvalContext({
|
|
46
|
+
...message.context,
|
|
47
|
+
exec: (cmd, opts) => rpc("exec", [cmd, opts]),
|
|
48
|
+
query: (request) => rpc("query", [request]),
|
|
49
|
+
redact: (text) => rpc("redact", [text]),
|
|
50
|
+
getClient,
|
|
51
|
+
});
|
|
52
|
+
const result = await scorer(context);
|
|
53
|
+
port.postMessage({ type: "result", result });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
port.postMessage({
|
|
56
|
+
type: "failure",
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
judgments: (error as { judgments?: unknown })?.judgments,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v3 runtime capability surface: everything the environment offers the
|
|
3
|
+
* agent and the scorer — per-attempt provisioning, container image selection,
|
|
4
|
+
* egress policy, MCP configuration, native-web policy, CLI availability, and
|
|
5
|
+
* scorer capabilities — declared as optional methods on the experiment's
|
|
6
|
+
* runtime instead of scattered per-surface special cases.
|
|
7
|
+
*
|
|
8
|
+
* Identity note: these are FUNCTIONS, and the manifest hashes the runtime as
|
|
9
|
+
* plain data (`{kind, image, command, config}` via canonical JSON, which
|
|
10
|
+
* drops functions). Anything identity-bearing a capability closes over MUST
|
|
11
|
+
* also appear in `Runtime.config` — the harness cannot see inside a closure.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { EvalMetadata } from "../evals/types.ts";
|
|
15
|
+
import type { McpServerConfig } from "../isolation/mcp.ts";
|
|
16
|
+
|
|
17
|
+
/** What a runtime's `provision` receives, per attempt. */
|
|
18
|
+
export interface RuntimeProvisionContext {
|
|
19
|
+
/** Aborted when the current stage expires. Implementations must stop work. */
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
attemptId: string;
|
|
22
|
+
evalId: string;
|
|
23
|
+
/** The eval's frontmatter metadata (`product`/`framework` drive provisioning). */
|
|
24
|
+
metadata: EvalMetadata;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What one provisioned attempt gets. `env` values are secrets — never log. */
|
|
28
|
+
export interface ProvisionedEnvironment {
|
|
29
|
+
/** Env vars delivered to the attempt container via the 0600 env-file, never argv. */
|
|
30
|
+
env?: Record<string, string>;
|
|
31
|
+
/** Credential identifier for teardown/leak accounting — never the secret itself. */
|
|
32
|
+
credentialRef?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Scrub what provision created. Failures are recorded explicitly on the
|
|
35
|
+
* attempt, never silently swallowed, and never crash the run.
|
|
36
|
+
*/
|
|
37
|
+
teardown?(signal?: AbortSignal): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What `scorerCapabilities` receives when the runner builds the scorer's context. */
|
|
41
|
+
export interface ScorerCapabilityContext {
|
|
42
|
+
/** Aborted when the current stage expires. Implementations must stop work. */
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
attemptId: string;
|
|
45
|
+
evalId: string;
|
|
46
|
+
experimentId: string;
|
|
47
|
+
metadata: EvalMetadata;
|
|
48
|
+
/** The provisioned env (empty when the runtime does not provision). */
|
|
49
|
+
env: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Capability implementations handed to the scorer's EvalContext. A helper the
|
|
54
|
+
* runtime does not provide throws at call time — there is no binding phase.
|
|
55
|
+
*/
|
|
56
|
+
export interface ScorerCapabilities {
|
|
57
|
+
query?(request: unknown): Promise<unknown>;
|
|
58
|
+
getClient?(name: string): unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Native web-browsing policy for the agent (recorded; enforced by adapters). */
|
|
62
|
+
export type WebPolicy = "native-web-allowed" | "native-web-blocked";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The optional capability methods a runtime may implement. All optional:
|
|
66
|
+
* a plain-data runtime (`{kind: "host"}`) is a complete runtime. The
|
|
67
|
+
* identity-bearing counterpart is `Runtime.config` (see identity note above).
|
|
68
|
+
*/
|
|
69
|
+
export interface RuntimeCapabilities {
|
|
70
|
+
/** Provision per-attempt resources BEFORE the sandbox exists (fail = no agent spend). */
|
|
71
|
+
provision?(ctx: RuntimeProvisionContext): Promise<ProvisionedEnvironment>;
|
|
72
|
+
/**
|
|
73
|
+
* Runtime-level teardown, used when `provision`'s result carries no
|
|
74
|
+
* `teardown` of its own (and attempted after a failed provision).
|
|
75
|
+
*/
|
|
76
|
+
teardown?(ctx: RuntimeProvisionContext): Promise<void>;
|
|
77
|
+
/** Resolve the container image for one eval (overridden by a static `image`). */
|
|
78
|
+
containerImage?(metadata: EvalMetadata): string;
|
|
79
|
+
/** Hosts this attempt may reach when the egress proxy is enabled. */
|
|
80
|
+
egressHosts?(metadata: EvalMetadata): readonly string[];
|
|
81
|
+
/** MCP servers the environment offers; authed entries ride the token proxy. */
|
|
82
|
+
mcpServers?(): Record<string, McpServerConfig>;
|
|
83
|
+
/** Native web tool policy for the agent. */
|
|
84
|
+
webPolicy?(): WebPolicy;
|
|
85
|
+
/** Directory prepended to the container PATH (the CLI treatment), if any. */
|
|
86
|
+
pathTreatment?(): string | undefined;
|
|
87
|
+
/** Capabilities (`query`, `getClient`) exposed to the eval's scorer. */
|
|
88
|
+
scorerCapabilities?(ctx: ScorerCapabilityContext): ScorerCapabilities;
|
|
89
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container environment assembly and secret hygiene.
|
|
3
|
+
*
|
|
4
|
+
* Two sources feed an attempt's environment:
|
|
5
|
+
* (a) shared provider keys, read from the HOST env per selected adapter
|
|
6
|
+
* (ANTHROPIC_API_KEY for claude, OPENAI_API_KEY for codex; each
|
|
7
|
+
* adapter declares its own `requiredHostEnv`, nothing here is
|
|
8
|
+
* per-vendor) — validated fail-fast before any container is created,
|
|
9
|
+
* and each attempt only receives the keys its own adapter requires;
|
|
10
|
+
* (b) per-attempt credentials from the experiment runtime's provision
|
|
11
|
+
* hook.
|
|
12
|
+
*
|
|
13
|
+
* Invariants: secret VALUES are never logged and never written to artifacts —
|
|
14
|
+
* `credential_ref` (an identifier, never the secret) is what lands in the
|
|
15
|
+
* attempt row, and every transcript/stderr write passes through
|
|
16
|
+
* {@link redactSecrets} first.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A provider key required by a selected adapter is absent from the host env. */
|
|
20
|
+
export class MissingProviderKeyError extends Error {
|
|
21
|
+
readonly adapter: string;
|
|
22
|
+
readonly envVar: string;
|
|
23
|
+
|
|
24
|
+
constructor(adapter: string, envVar: string) {
|
|
25
|
+
super(
|
|
26
|
+
`agent "${adapter}" requires the ${envVar} environment variable, which is not set — ` +
|
|
27
|
+
`export it before running (it is injected into attempt containers at create, never mounted or persisted)`,
|
|
28
|
+
);
|
|
29
|
+
this.name = "MissingProviderKeyError";
|
|
30
|
+
this.adapter = adapter;
|
|
31
|
+
this.envVar = envVar;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A provider key is present but the provider's API rejected it. */
|
|
36
|
+
export class InvalidProviderKeyError extends Error {
|
|
37
|
+
constructor(adapter: string, envVars: readonly string[], host: string, status: number) {
|
|
38
|
+
super(
|
|
39
|
+
`run not starting: agent "${adapter}" key (${envVars.join(", ")}) was rejected by ${host} (HTTP ${status}) — ` +
|
|
40
|
+
`nothing was created; fix or re-export the key and re-run`,
|
|
41
|
+
);
|
|
42
|
+
this.name = "InvalidProviderKeyError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProviderKeyRequirement {
|
|
47
|
+
/** The adapter (agent axis value) that needs the keys. */
|
|
48
|
+
name: string;
|
|
49
|
+
/** Host env var names the adapter requires. */
|
|
50
|
+
requiredHostEnv: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One preflight probe: a cheap authenticated GET a provider will 401 on a bad key. */
|
|
54
|
+
export interface ProviderKeyProbe {
|
|
55
|
+
/** The adapter whose key is being probed. */
|
|
56
|
+
name: string;
|
|
57
|
+
/** The env var names the probe's key came from (for messages; values never appear). */
|
|
58
|
+
envVars: readonly string[];
|
|
59
|
+
url: string;
|
|
60
|
+
headers: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Probe responses take at most this long before the run proceeds unvalidated. */
|
|
64
|
+
const KEY_PROBE_TIMEOUT_MS = 10_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Live provider-key preflight: probe each adapter's endpoint once, BEFORE any
|
|
68
|
+
* container or sidecar exists. Only an explicit auth rejection (401/403)
|
|
69
|
+
* stops the run — a key that is present but rejected would otherwise burn a
|
|
70
|
+
* container per attempt just to fail identically inside it. Network failures
|
|
71
|
+
* and non-auth statuses warn and proceed: an unreachable or rate-limiting
|
|
72
|
+
* probe is not evidence the key is bad.
|
|
73
|
+
*/
|
|
74
|
+
export async function validateProviderKeys(
|
|
75
|
+
probes: readonly ProviderKeyProbe[],
|
|
76
|
+
opts: { fetchImpl?: typeof fetch; log?: (line: string) => void; timeoutMs?: number } = {},
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
79
|
+
const log = opts.log ?? (() => {});
|
|
80
|
+
const timeoutMs = opts.timeoutMs ?? KEY_PROBE_TIMEOUT_MS;
|
|
81
|
+
for (const probe of probes) {
|
|
82
|
+
const host = new URL(probe.url).host;
|
|
83
|
+
let status: number;
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetchImpl(probe.url, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: probe.headers,
|
|
88
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
89
|
+
});
|
|
90
|
+
status = response.status;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log(
|
|
93
|
+
`warning: could not validate ${probe.envVars.join(", ")} against ${host} ` +
|
|
94
|
+
`(${err instanceof Error ? err.message : String(err)}) — starting anyway`,
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (status === 401 || status === 403) {
|
|
99
|
+
throw new InvalidProviderKeyError(probe.name, probe.envVars, host, status);
|
|
100
|
+
}
|
|
101
|
+
if (status >= 200 && status < 300) {
|
|
102
|
+
log(`preflight: ${probe.envVars.join(", ")} accepted by ${host}`);
|
|
103
|
+
} else {
|
|
104
|
+
log(`preflight: ${probe.envVars.join(", ")} check against ${host} inconclusive (HTTP ${status}) — starting anyway`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read every required provider key from the host env, failing fast — before
|
|
111
|
+
* any container is created — on the first missing or empty variable, naming
|
|
112
|
+
* both the variable and the agent that needs it.
|
|
113
|
+
*/
|
|
114
|
+
export function collectProviderKeys(
|
|
115
|
+
requirements: readonly ProviderKeyRequirement[],
|
|
116
|
+
hostEnv: Record<string, string | undefined> = process.env,
|
|
117
|
+
): Record<string, string> {
|
|
118
|
+
const keys: Record<string, string> = {};
|
|
119
|
+
for (const requirement of requirements) {
|
|
120
|
+
for (const envVar of requirement.requiredHostEnv) {
|
|
121
|
+
const value = hostEnv[envVar];
|
|
122
|
+
if (value === undefined || value === "") {
|
|
123
|
+
throw new MissingProviderKeyError(requirement.name, envVar);
|
|
124
|
+
}
|
|
125
|
+
keys[envVar] = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return keys;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Values shorter than this are never redacted (they would shred ordinary text). */
|
|
132
|
+
const MIN_REDACTABLE_LENGTH = 6;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Replace every known secret value in `text` with `[REDACTED:<name>]`.
|
|
136
|
+
* Applied to transcripts and stderr before they are written to artifacts —
|
|
137
|
+
* an agent that echoes its env must not persist a live key.
|
|
138
|
+
*/
|
|
139
|
+
export function redactSecrets(text: string, env: Record<string, string>): string {
|
|
140
|
+
let redacted = text;
|
|
141
|
+
for (const [name, value] of Object.entries(env)) {
|
|
142
|
+
if (value.length < MIN_REDACTABLE_LENGTH) continue;
|
|
143
|
+
redacted = redacted.replaceAll(value, `[REDACTED:${name}]`);
|
|
144
|
+
}
|
|
145
|
+
return redacted;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Provider/judge secret env only; ordinary PATH and workspace values are evidence. */
|
|
149
|
+
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>;
|
|
151
|
+
}
|