@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,148 @@
1
+ /**
2
+ * Egress allowlist: composition and matching.
3
+ *
4
+ * The allowlist composes from two layers, both recorded into the run's
5
+ * config_json for auditability:
6
+ * (1) harness defaults — each container agent adapter declares the LLM
7
+ * endpoints its CLI needs (`egressHosts` on the adapter);
8
+ * (2) runtime contribution — the experiment runtime's
9
+ * `egressHosts(metadata)` (product endpoints, package registries).
10
+ *
11
+ * Matching is deliberately narrow: exact hostnames and `*.domain` subdomain
12
+ * wildcards only — no path rules, no IP rules, no CIDR. Raw-IP CONNECT
13
+ * targets are refused outright (closing the resolve-then-dial DNS bypass),
14
+ * so IP literals in the allowlist itself are rejected at compose time.
15
+ */
16
+
17
+ /** An allowlist entry is invalid (IP literal, port, scheme, path, bad wildcard). */
18
+ export class AllowlistEntryError extends Error {
19
+ constructor(entry: string, problem: string) {
20
+ super(`invalid egress allowlist entry "${entry}": ${problem}`);
21
+ this.name = "AllowlistEntryError";
22
+ }
23
+ }
24
+
25
+ const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
26
+
27
+ /**
28
+ * Normalize one host for matching: lowercase, strip a single trailing dot
29
+ * (DNS absolute form — `evil.com.` resolves identically to `evil.com` and
30
+ * must match the same allowlist entries, never bypass them).
31
+ */
32
+ export function normalizeHost(host: string): string {
33
+ const lowered = host.toLowerCase();
34
+ return lowered.endsWith(".") ? lowered.slice(0, -1) : lowered;
35
+ }
36
+
37
+ /**
38
+ * Is this host an IP literal (any form)? The proxy refuses raw-IP CONNECTs
39
+ * regardless of allowlist, so this is deliberately over-broad: dotted quads,
40
+ * anything with a colon (IPv6), bracketed literals, all-digit decimal forms
41
+ * (`http://2130706433/`), and 0x-prefixed hex forms all count.
42
+ */
43
+ export function isRawIpHost(host: string): boolean {
44
+ const h = normalizeHost(host);
45
+ if (h.length === 0) return true;
46
+ if (h.includes(":") || h.startsWith("[")) return true; // IPv6 / bracketed
47
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return true; // dotted quad
48
+ if (/^\d+$/.test(h)) return true; // decimal int form
49
+ if (/^0x[0-9a-f]+$/.test(h)) return true; // hex int form
50
+ if (/^[\d.]+$/.test(h)) return true; // partial numeric forms (1.2.3, 1.2)
51
+ return false;
52
+ }
53
+
54
+ /** Validate one entry (exact host or `*.domain`); returns its normalized form. */
55
+ export function validateAllowlistEntry(entry: string): string {
56
+ const normalized = normalizeHost(entry.trim());
57
+ if (normalized.length === 0) throw new AllowlistEntryError(entry, "empty");
58
+ if (/[/?#\s]/.test(normalized) || normalized.includes("://")) {
59
+ throw new AllowlistEntryError(entry, "must be a bare hostname (no scheme, path, or spaces)");
60
+ }
61
+ if (normalized.includes(":")) {
62
+ throw new AllowlistEntryError(entry, "must not carry a port (allowlisting is hostname-granularity)");
63
+ }
64
+ const body = normalized.startsWith("*.") ? normalized.slice(2) : normalized;
65
+ if (body.includes("*")) {
66
+ throw new AllowlistEntryError(entry, "wildcards are subdomain-only: a single leading `*.` label");
67
+ }
68
+ if (isRawIpHost(body)) {
69
+ throw new AllowlistEntryError(entry, "IP literals are refused by the proxy — allowlist hostnames only");
70
+ }
71
+ // Single-label hosts are legal: Docker-network service names ("origin")
72
+ // and localhost matter for tests and runtime-provisioned local services.
73
+ const labels = body.split(".");
74
+ if (!labels.every((label) => LABEL.test(label))) {
75
+ throw new AllowlistEntryError(entry, "not a valid hostname");
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ /**
81
+ * Does `host` match the allowlist? Exact entries match the whole hostname
82
+ * (case-insensitively, trailing dot stripped); `*.domain` entries match any
83
+ * subdomain of `domain` — at least one extra label, never `domain` itself
84
+ * (which is why runtimes list both `example.com` and `*.example.com`).
85
+ * IP literals never match anything.
86
+ */
87
+ export function hostAllowed(host: string, allowlist: readonly string[]): boolean {
88
+ const h = normalizeHost(host);
89
+ if (isRawIpHost(h)) return false;
90
+ for (const entry of allowlist) {
91
+ const e = normalizeHost(entry);
92
+ if (e.startsWith("*.")) {
93
+ const suffix = e.slice(1); // ".domain"
94
+ if (h.endsWith(suffix) && h.length > suffix.length) return true;
95
+ } else if (h === e) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Parse a CONNECT authority-form target (`host:port`, `[v6]:port`) or a bare
104
+ * host. Returns undefined for garbage. The port defaults to 443 — CONNECT
105
+ * targets are overwhelmingly TLS.
106
+ */
107
+ export function parseHostPort(target: string): { host: string; port: number } | undefined {
108
+ const trimmed = target.trim();
109
+ if (trimmed.length === 0) return undefined;
110
+ let host: string;
111
+ let portText: string | undefined;
112
+ if (trimmed.startsWith("[")) {
113
+ // Bracketed IPv6 literal: [::1]:443 — kept intact so isRawIpHost sees it.
114
+ const end = trimmed.indexOf("]");
115
+ if (end === -1) return undefined;
116
+ host = trimmed.slice(0, end + 1);
117
+ const rest = trimmed.slice(end + 1);
118
+ if (rest.startsWith(":")) portText = rest.slice(1);
119
+ else if (rest !== "") return undefined;
120
+ } else {
121
+ const colon = trimmed.lastIndexOf(":");
122
+ // A second colon means an unbracketed IPv6 literal: keep whole as host.
123
+ if (colon !== -1 && trimmed.indexOf(":") === colon) {
124
+ host = trimmed.slice(0, colon);
125
+ portText = trimmed.slice(colon + 1);
126
+ } else {
127
+ host = trimmed;
128
+ }
129
+ }
130
+ if (host.length === 0) return undefined;
131
+ if (portText === undefined) return { host, port: 443 };
132
+ if (!/^\d{1,5}$/.test(portText)) return undefined;
133
+ const port = Number(portText);
134
+ if (port < 1 || port > 65535) return undefined;
135
+ return { host, port };
136
+ }
137
+
138
+ /**
139
+ * Compose a validated allowlist from an explicit host list (the runner
140
+ * unions runtime- and adapter-declared hosts before calling). Validated,
141
+ * normalized, deduplicated, sorted — the sorted list is what lands in
142
+ * config_json, so two identical runs record identical allowlists.
143
+ */
144
+ export function composeAllowlistFromHosts(hosts: readonly string[]): string[] {
145
+ const entries = new Set<string>();
146
+ for (const host of hosts) entries.add(validateAllowlistEntry(host));
147
+ return [...entries].sort();
148
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The egress allowlist proxy: a small HTTP CONNECT / absolute-form forward
3
+ * proxy (no squid dependency, no TLS interception).
4
+ *
5
+ * Attempt containers sit on an internal Docker network with no default
6
+ * route; this proxy straddles that network and the bridge, and the attempt
7
+ * env carries HTTP_PROXY/HTTPS_PROXY pointing here. For HTTPS the proxy sees
8
+ * only the CONNECT `host:port` — allowlist enforcement is hostname-granular,
9
+ * traffic is never decrypted. Deny-by-default; denials answer 403 naming the
10
+ * host; raw-IP CONNECT targets are refused outright (no DNS-bypass hole).
11
+ *
12
+ * Every decision (allowed AND denied) is emitted as one stable JSON event —
13
+ * to `onEvent` in-process, and as a JSON line on stdout when run as the
14
+ * sidecar entrypoint. Denials become the attempt's `egress-denials.log`
15
+ * artifact; zero allowed connections from an errored attempt is the
16
+ * `egress_no_route` signal (an agent whose HTTP stack ignored proxy env).
17
+ *
18
+ * Threat model (also in README): a MISBEHAVING agent, not a determined
19
+ * adversary — a hostile workload could still tunnel over an allowlisted
20
+ * host.
21
+ */
22
+
23
+ import { connect, createServer, type Server, type Socket } from "node:net";
24
+ import { hostAllowed, isRawIpHost, normalizeHost, parseHostPort, validateAllowlistEntry } from "./allowlist.ts";
25
+
26
+ /** One proxy decision. The line shape is stable — consumers parse it. */
27
+ export interface EgressEvent {
28
+ type: "egress";
29
+ /** ISO-8601 timestamp. */
30
+ ts: string;
31
+ /** Client IP (the attempt container's internal-network address). */
32
+ client: string;
33
+ /** "CONNECT" for tunnels, the HTTP method for plain forwards. */
34
+ method: string;
35
+ host: string;
36
+ port: number;
37
+ decision: "allowed" | "denied";
38
+ /** Present on denials only. */
39
+ reason?: "not_allowlisted" | "raw_ip" | "bad_request";
40
+ }
41
+
42
+ export interface EgressProxyOptions {
43
+ /** Validated at start; entries are exact hosts or `*.domain` wildcards. */
44
+ allowlist: readonly string[];
45
+ /** 0 (default) picks a random free port. Sidecar runs pass a fixed one. */
46
+ port?: number;
47
+ /** Bind address. Default 0.0.0.0 (the sidecar must accept from the internal network). */
48
+ hostname?: string;
49
+ /** Decision sink (both allowed and denied). */
50
+ onEvent?: (event: EgressEvent) => void;
51
+ }
52
+
53
+ export interface EgressProxyHandle {
54
+ port: number;
55
+ close(): Promise<void>;
56
+ }
57
+
58
+ /** How long a client may take to send its request head. */
59
+ const HEAD_TIMEOUT_MS = 30_000;
60
+ /** How long an upstream TCP connect may take. */
61
+ const CONNECT_TIMEOUT_MS = 20_000;
62
+ /** Request heads larger than this are dropped (nothing legitimate is close). */
63
+ const MAX_HEAD_BYTES = 32 * 1024;
64
+
65
+ /** Hop-by-hop headers never forwarded upstream on plain-HTTP forwards. */
66
+ const DROPPED_HEADERS = /^(proxy-connection|proxy-authorization|connection|keep-alive|te|upgrade|transfer-encoding)$/i;
67
+
68
+ function writeAndClose(socket: Socket, response: string): void {
69
+ socket.write(response, () => socket.end());
70
+ }
71
+
72
+ function httpError(status: number, statusText: string, body: string): string {
73
+ return (
74
+ `HTTP/1.1 ${status} ${statusText}\r\n` +
75
+ "Content-Type: text/plain; charset=utf-8\r\n" +
76
+ `Content-Length: ${Buffer.byteLength(body)}\r\n` +
77
+ "Connection: close\r\n\r\n" +
78
+ body
79
+ );
80
+ }
81
+
82
+ /** Start the proxy. Resolves once listening. */
83
+ export function startEgressProxy(options: EgressProxyOptions): Promise<EgressProxyHandle> {
84
+ // Fail fast on a malformed allowlist — a sidecar that silently allowed
85
+ // nothing (or crashed per-request) would fail every attempt wholesale.
86
+ const allowlist = options.allowlist.map(validateAllowlistEntry);
87
+ const onEvent = options.onEvent ?? (() => {});
88
+
89
+ const server: Server = createServer((client) => {
90
+ client.on("error", () => client.destroy());
91
+ const clientIp = client.remoteAddress ?? "unknown";
92
+ let buffered = Buffer.alloc(0);
93
+ let done = false;
94
+
95
+ const headTimer = setTimeout(() => {
96
+ if (!done) client.destroy();
97
+ }, HEAD_TIMEOUT_MS);
98
+
99
+ const emit = (
100
+ method: string,
101
+ host: string,
102
+ port: number,
103
+ decision: EgressEvent["decision"],
104
+ reason?: EgressEvent["reason"],
105
+ ): void => {
106
+ onEvent({
107
+ type: "egress",
108
+ ts: new Date().toISOString(),
109
+ client: clientIp,
110
+ method,
111
+ host,
112
+ port,
113
+ decision,
114
+ ...(reason ? { reason } : {}),
115
+ });
116
+ };
117
+
118
+ const onData = (chunk: Buffer): void => {
119
+ buffered = Buffer.concat([buffered, chunk]);
120
+ const headEnd = buffered.indexOf("\r\n\r\n");
121
+ if (headEnd === -1) {
122
+ if (buffered.length > MAX_HEAD_BYTES) {
123
+ done = true;
124
+ clearTimeout(headTimer);
125
+ writeAndClose(client, httpError(400, "Bad Request", "request head too large\n"));
126
+ }
127
+ return;
128
+ }
129
+ done = true;
130
+ clearTimeout(headTimer);
131
+ client.removeListener("data", onData);
132
+ client.pause();
133
+
134
+ const head = buffered.subarray(0, headEnd).toString("latin1");
135
+ const remainder = buffered.subarray(headEnd + 4);
136
+ buffered = Buffer.alloc(0);
137
+ handleRequest(client, head, remainder, emit, allowlist);
138
+ };
139
+ client.on("data", onData);
140
+ });
141
+
142
+ // Track live client sockets so close() can sever open tunnels — a
143
+ // long-lived CONNECT tunnel would otherwise keep the server open forever.
144
+ const open = new Set<Socket>();
145
+ server.on("connection", (socket) => {
146
+ open.add(socket);
147
+ socket.on("close", () => open.delete(socket));
148
+ });
149
+
150
+ return new Promise((resolve, reject) => {
151
+ server.once("error", reject);
152
+ server.listen(options.port ?? 0, options.hostname ?? "0.0.0.0", () => {
153
+ const address = server.address();
154
+ const port = typeof address === "object" && address !== null ? address.port : 0;
155
+ resolve({
156
+ port,
157
+ close: () =>
158
+ new Promise<void>((done) => {
159
+ for (const socket of open) socket.destroy();
160
+ server.close(() => done());
161
+ }),
162
+ });
163
+ });
164
+ });
165
+ }
166
+
167
+ function handleRequest(
168
+ client: Socket,
169
+ head: string,
170
+ remainder: Buffer,
171
+ emit: (
172
+ method: string,
173
+ host: string,
174
+ port: number,
175
+ decision: EgressEvent["decision"],
176
+ reason?: EgressEvent["reason"],
177
+ ) => void,
178
+ allowlist: readonly string[],
179
+ ): void {
180
+ const lines = head.split("\r\n");
181
+ const requestLine = lines[0] ?? "";
182
+ const parts = requestLine.split(" ");
183
+ if (parts.length !== 3) {
184
+ emit(parts[0] ?? "?", "", 0, "denied", "bad_request");
185
+ writeAndClose(client, httpError(400, "Bad Request", `malformed request line: "${requestLine}"\n`));
186
+ return;
187
+ }
188
+ const [method, target] = parts as [string, string, string];
189
+
190
+ if (method === "CONNECT") {
191
+ handleConnect(client, target, remainder, emit, allowlist);
192
+ } else {
193
+ handleForward(client, method, target, lines.slice(1), remainder, emit, allowlist);
194
+ }
195
+ }
196
+
197
+ /** Shared allow/deny gate. Writes the 403 (naming the host) on denial. */
198
+ function gate(
199
+ client: Socket,
200
+ method: string,
201
+ host: string,
202
+ port: number,
203
+ emit: (
204
+ method: string,
205
+ host: string,
206
+ port: number,
207
+ decision: EgressEvent["decision"],
208
+ reason?: EgressEvent["reason"],
209
+ ) => void,
210
+ allowlist: readonly string[],
211
+ ): boolean {
212
+ const normalized = normalizeHost(host);
213
+ if (isRawIpHost(normalized)) {
214
+ emit(method, normalized, port, "denied", "raw_ip");
215
+ writeAndClose(
216
+ client,
217
+ httpError(403, "Forbidden", `egress blocked: raw-IP targets are refused ("${normalized}") — use a hostname\n`),
218
+ );
219
+ return false;
220
+ }
221
+ if (!hostAllowed(normalized, allowlist)) {
222
+ emit(method, normalized, port, "denied", "not_allowlisted");
223
+ writeAndClose(
224
+ client,
225
+ httpError(403, "Forbidden", `egress blocked: "${normalized}" is not on this run's egress allowlist\n`),
226
+ );
227
+ return false;
228
+ }
229
+ emit(method, normalized, port, "allowed");
230
+ return true;
231
+ }
232
+
233
+ /** Dial upstream with a connect timeout; invoke ready exactly once on success. */
234
+ function dialUpstream(client: Socket, host: string, port: number, ready: (upstream: Socket) => void): void {
235
+ const upstream = connect(port, host);
236
+ let established = false;
237
+ const connectTimer = setTimeout(() => {
238
+ if (!established) upstream.destroy(new Error("connect timeout"));
239
+ }, CONNECT_TIMEOUT_MS);
240
+ upstream.once("connect", () => {
241
+ established = true;
242
+ clearTimeout(connectTimer);
243
+ ready(upstream);
244
+ });
245
+ upstream.on("error", (err) => {
246
+ clearTimeout(connectTimer);
247
+ if (!established) {
248
+ writeAndClose(client, httpError(502, "Bad Gateway", `upstream connect to ${host}:${port} failed: ${err.message}\n`));
249
+ } else {
250
+ client.destroy();
251
+ }
252
+ });
253
+ }
254
+
255
+ /** Bidirectional tunnel wiring shared by CONNECT and plain forwards. */
256
+ function pipeBoth(client: Socket, upstream: Socket): void {
257
+ client.pipe(upstream);
258
+ upstream.pipe(client);
259
+ client.on("close", () => upstream.destroy());
260
+ upstream.on("close", () => client.destroy());
261
+ client.on("error", () => upstream.destroy());
262
+ }
263
+
264
+ /** CONNECT host:port — the HTTPS path. The tunnel is opaque to the proxy. */
265
+ function handleConnect(
266
+ client: Socket,
267
+ target: string,
268
+ remainder: Buffer,
269
+ emit: (
270
+ method: string,
271
+ host: string,
272
+ port: number,
273
+ decision: EgressEvent["decision"],
274
+ reason?: EgressEvent["reason"],
275
+ ) => void,
276
+ allowlist: readonly string[],
277
+ ): void {
278
+ const parsed = parseHostPort(target);
279
+ if (!parsed) {
280
+ emit("CONNECT", target, 0, "denied", "bad_request");
281
+ writeAndClose(client, httpError(400, "Bad Request", `malformed CONNECT target: "${target}"\n`));
282
+ return;
283
+ }
284
+ if (!gate(client, "CONNECT", parsed.host, parsed.port, emit, allowlist)) return;
285
+
286
+ dialUpstream(client, normalizeHost(parsed.host), parsed.port, (upstream) => {
287
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
288
+ // Bytes the client sent eagerly after its head (e.g. an early TLS hello).
289
+ if (remainder.length > 0) upstream.write(remainder);
290
+ pipeBoth(client, upstream);
291
+ client.resume();
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Plain-HTTP forward: proxy clients send absolute-form URIs
297
+ * (`GET http://host/path HTTP/1.1`). The request is rewritten to origin form
298
+ * with `Connection: close` (one exchange per connection — simple beats
299
+ * keep-alive bookkeeping in a harness proxy).
300
+ */
301
+ function handleForward(
302
+ client: Socket,
303
+ method: string,
304
+ target: string,
305
+ headerLines: string[],
306
+ remainder: Buffer,
307
+ emit: (
308
+ method: string,
309
+ host: string,
310
+ port: number,
311
+ decision: EgressEvent["decision"],
312
+ reason?: EgressEvent["reason"],
313
+ ) => void,
314
+ allowlist: readonly string[],
315
+ ): void {
316
+ let url: URL;
317
+ try {
318
+ url = new URL(target);
319
+ } catch {
320
+ emit(method, target, 0, "denied", "bad_request");
321
+ writeAndClose(
322
+ client,
323
+ httpError(400, "Bad Request", `expected an absolute-form proxy request URI, got "${target}"\n`),
324
+ );
325
+ return;
326
+ }
327
+ if (url.protocol !== "http:") {
328
+ emit(method, url.hostname, 0, "denied", "bad_request");
329
+ writeAndClose(client, httpError(400, "Bad Request", `unsupported scheme "${url.protocol}" (https uses CONNECT)\n`));
330
+ return;
331
+ }
332
+ const host = url.hostname;
333
+ const port = url.port === "" ? 80 : Number(url.port);
334
+ if (!gate(client, method, host, port, emit, allowlist)) return;
335
+
336
+ const kept = headerLines.filter((line) => !DROPPED_HEADERS.test(line.split(":")[0] ?? ""));
337
+ const originForm = `${url.pathname}${url.search}`;
338
+ // Head assembled as lines and joined ONCE: an empty `kept` must not leave
339
+ // a blank line after the request line (that would end the header block
340
+ // early and push "Connection: close" into the body).
341
+ const rewritten = [`${method} ${originForm === "" ? "/" : originForm} HTTP/1.1`, ...kept, "Connection: close", "", ""].join(
342
+ "\r\n",
343
+ );
344
+
345
+ dialUpstream(client, normalizeHost(host), port, (upstream) => {
346
+ upstream.write(rewritten);
347
+ if (remainder.length > 0) upstream.write(remainder);
348
+ pipeBoth(client, upstream);
349
+ client.resume();
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Sidecar entrypoint (images/egress-proxy). Config via env:
355
+ * QUICKSTUDY_EGRESS_ALLOWLIST comma-separated entries (required)
356
+ * QUICKSTUDY_EGRESS_PORT listen port (default 3128)
357
+ * Every decision prints as one JSON line on stdout; the harness reads them
358
+ * back with `docker logs` for per-attempt denial artifacts.
359
+ */
360
+ if (import.meta.main) {
361
+ const rawList = process.env["QUICKSTUDY_EGRESS_ALLOWLIST"] ?? "";
362
+ const allowlist = rawList
363
+ .split(",")
364
+ .map((entry) => entry.trim())
365
+ .filter((entry) => entry !== "");
366
+ const port = Number(process.env["QUICKSTUDY_EGRESS_PORT"] ?? "3128");
367
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
368
+ console.error(`egress-proxy: invalid QUICKSTUDY_EGRESS_PORT "${process.env["QUICKSTUDY_EGRESS_PORT"]}"`);
369
+ process.exit(1);
370
+ }
371
+ try {
372
+ const handle = await startEgressProxy({
373
+ allowlist,
374
+ port,
375
+ onEvent: (event) => console.log(JSON.stringify(event)),
376
+ });
377
+ console.log(JSON.stringify({ type: "listening", port: handle.port, allowlist }));
378
+ } catch (err) {
379
+ console.error(`egress-proxy: failed to start: ${err instanceof Error ? err.message : String(err)}`);
380
+ process.exit(1);
381
+ }
382
+ }
package/src/llm.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The one seam between quickstudy and the model provider: a tiny
3
+ * structured-output client interface, an Anthropic-backed implementation,
4
+ * and a validate-with-one-re-ask helper.
5
+ *
6
+ * Every host-side LLM call goes through this seam, so tests mock a single
7
+ * interface and never touch the network. Design rules:
8
+ * - The provider client owns transport retries (3x on 429/5xx/connection).
9
+ * - Structured output is requested via `output_config.format` json_schema,
10
+ * so parsing never fails silently; a schema-invalid response gets exactly
11
+ * ONE re-ask with the error appended, then becomes a hard
12
+ * `ModelOutputError` the caller handles per its own error contract.
13
+ */
14
+
15
+ import Anthropic from "@anthropic-ai/sdk";
16
+ import type { z } from "zod";
17
+
18
+ /** One structured-output request. `schema` is a JSON Schema object. */
19
+ export interface StructuredRequest {
20
+ model: string;
21
+ system: string;
22
+ user: string;
23
+ maxTokens: number;
24
+ /** JSON Schema the response must satisfy (objects need additionalProperties:false). */
25
+ schema: Record<string, unknown>;
26
+ }
27
+
28
+ /** The seam tests mock. Returns the model's raw text response. */
29
+ export interface StructuredModelClient {
30
+ requestStructured(request: StructuredRequest): Promise<string>;
31
+ /**
32
+ * The model id the provider reported for the most recent response, when
33
+ * known — provenance for artifacts (a pinned alias may resolve elsewhere).
34
+ */
35
+ resolvedModel?(): string | null;
36
+ }
37
+
38
+ /** The model responded, but not with schema-valid output (after one re-ask). */
39
+ export class ModelOutputError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "ModelOutputError";
43
+ }
44
+ }
45
+
46
+ /** Spec: retry API failures 3x before surfacing them. */
47
+ const API_MAX_RETRIES = 3;
48
+
49
+ /**
50
+ * Anthropic-backed client. Reuses ANTHROPIC_API_KEY from the environment;
51
+ * transport-level retries are the SDK's (429/5xx/connection errors).
52
+ */
53
+ export class AnthropicModelClient implements StructuredModelClient {
54
+ private readonly client: Anthropic;
55
+ private lastResolvedModel: string | null = null;
56
+
57
+ constructor(options: { apiKey?: string } = {}) {
58
+ this.client = new Anthropic({
59
+ ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
60
+ maxRetries: API_MAX_RETRIES,
61
+ });
62
+ }
63
+
64
+ resolvedModel(): string | null {
65
+ return this.lastResolvedModel;
66
+ }
67
+
68
+ async requestStructured(request: StructuredRequest): Promise<string> {
69
+ const response = await this.client.messages.create({
70
+ model: request.model,
71
+ max_tokens: request.maxTokens,
72
+ system: request.system,
73
+ messages: [{ role: "user", content: request.user }],
74
+ output_config: { format: { type: "json_schema", schema: request.schema } },
75
+ });
76
+ this.lastResolvedModel = response.model;
77
+
78
+ if (response.stop_reason === "refusal") {
79
+ throw new ModelOutputError(`model refused the request (model: ${request.model})`);
80
+ }
81
+ if (response.stop_reason === "max_tokens") {
82
+ throw new ModelOutputError(
83
+ `model output truncated at ${request.maxTokens} tokens (model: ${request.model})`,
84
+ );
85
+ }
86
+
87
+ const text = response.content.find((block) => block.type === "text")?.text;
88
+ if (text === undefined) {
89
+ throw new ModelOutputError(`model returned no text content (model: ${request.model})`);
90
+ }
91
+ return text;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Request + parse + zod-validate, with exactly one re-ask on invalid output:
97
+ * the retry prompt appends the previous (bad) response and the validation
98
+ * error. A second failure is a hard ModelOutputError — never a silent skip.
99
+ */
100
+ export async function requestValidated<T>(
101
+ client: StructuredModelClient,
102
+ request: StructuredRequest,
103
+ schema: z.ZodType<T>,
104
+ ): Promise<T> {
105
+ const attempt = (raw: string): { ok: true; value: T } | { ok: false; error: string } => {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ } catch (err) {
110
+ return { ok: false, error: `response is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
111
+ }
112
+ const result = schema.safeParse(parsed);
113
+ if (result.success) return { ok: true, value: result.data };
114
+ return { ok: false, error: `response does not match the schema: ${result.error.message}` };
115
+ };
116
+
117
+ const first = attempt(await client.requestStructured(request));
118
+ if (first.ok) return first.value;
119
+
120
+ const reasked = attempt(
121
+ await client.requestStructured({
122
+ ...request,
123
+ user:
124
+ `${request.user}\n\n` +
125
+ `Your previous response was invalid — ${first.error}\n` +
126
+ `Respond again with ONLY a JSON object that matches the required schema exactly.`,
127
+ }),
128
+ );
129
+ if (reasked.ok) return reasked.value;
130
+
131
+ throw new ModelOutputError(`model output invalid after one re-ask: ${reasked.error}`);
132
+ }