@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,927 @@
1
+ /**
2
+ * AUTO-GENERATED — do not edit by hand.
3
+ *
4
+ * The harness image build contexts (Dockerfiles + the proxy sources they
5
+ * COPY), embedded so the compiled binary can `images build` without a
6
+ * checkout: resolveImagesRoot materializes these into a temp directory and
7
+ * `docker build` runs against that. Paths are repo-relative and mirror the
8
+ * source tree exactly.
9
+ *
10
+ * Regenerate with: bun scripts/generate-image-contexts.ts
11
+ * (tests/image-contexts.test.ts fails when this module drifts from source.)
12
+ */
13
+
14
+ export const IMAGE_CONTEXT_FILES: Record<string, string> = {
15
+ "images/agent-runtime/Dockerfile": `# quickstudy agent-runtime base image.
16
+ #
17
+ # Debian-slim + git + node, with the agent CLIs installed at PINNED versions:
18
+ # vendor CLIs change their stream formats between releases, and the parsers in
19
+ # src/adapters/parse.ts are tested against fixtures captured from these exact
20
+ # versions. Bump the pins deliberately (\`quickstudy images build --pull\`) and
21
+ # re-capture fixtures when you do.
22
+ #
23
+ # Framework toolchains (Rails, Django, ...) layer on top in Phase 3 — this
24
+ # image stays agent-only.
25
+
26
+ FROM node:22-bookworm-slim
27
+
28
+ ARG CLAUDE_CODE_VERSION=2.1.215
29
+ ARG CODEX_VERSION=0.149.1
30
+ ARG GEMINI_CLI_VERSION=0.51.0
31
+ # cursor-agent is not an npm package: it ships as versioned tarballs (the
32
+ # vendor install script pins the same way). Fixture streams in
33
+ # tests/__fixtures__/streams/cursor/ correspond to this pin.
34
+ ARG CURSOR_AGENT_VERSION=2026.07.17-3e2a980
35
+
36
+ RUN apt-get update \\
37
+ && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep \\
38
+ && rm -rf /var/lib/apt/lists/*
39
+
40
+ RUN npm install -g --no-fund --no-audit \\
41
+ "@anthropic-ai/claude-code@\${CLAUDE_CODE_VERSION}" \\
42
+ "@openai/codex@\${CODEX_VERSION}" \\
43
+ "@google/gemini-cli@\${GEMINI_CLI_VERSION}"
44
+
45
+ RUN arch="$(dpkg --print-architecture)" \\
46
+ && case "$arch" in \\
47
+ amd64) cursor_arch=x64 ;; \\
48
+ arm64) cursor_arch=arm64 ;; \\
49
+ *) echo "unsupported architecture for cursor-agent: $arch" >&2; exit 1 ;; \\
50
+ esac \\
51
+ && mkdir -p /opt/cursor-agent \\
52
+ && curl -fsSL "https://downloads.cursor.com/lab/\${CURSOR_AGENT_VERSION}/linux/\${cursor_arch}/agent-cli-package.tar.gz" \\
53
+ | tar --strip-components=1 -xzf - -C /opt/cursor-agent \\
54
+ && ln -s /opt/cursor-agent/cursor-agent /usr/local/bin/cursor-agent
55
+
56
+ # The fixture-baseline commit (and any commits the agent makes) need an
57
+ # identity; system-level config applies to whichever user runs.
58
+ RUN git config --system user.name "quickstudy" \\
59
+ && git config --system user.email "quickstudy@localhost" \\
60
+ && git config --system init.defaultBranch main \\
61
+ && git config --system --add safe.directory /workspace
62
+
63
+ # Claude Code refuses --dangerously-skip-permissions as root unless it can
64
+ # tell it is inside a sandbox. The container IS the security boundary here —
65
+ # that is the entire point of this phase.
66
+ ENV IS_SANDBOX=1
67
+
68
+ WORKDIR /workspace
69
+
70
+ # The harness drives everything through \`docker exec\`; the main process just
71
+ # keeps the container alive until teardown.
72
+ CMD ["sleep", "infinity"]
73
+ `,
74
+ "images/egress-proxy/Dockerfile": `# quickstudy egress-proxy sidecar image.
75
+ #
76
+ # One per run: attempt containers sit on an internal Docker network with no
77
+ # default route, and this proxy (attached to BOTH the internal network and
78
+ # the bridge) is their only way out — an HTTP CONNECT / absolute-form forward
79
+ # proxy enforcing the run's host allowlist (src/isolation/proxy/server.ts).
80
+ # No TLS interception: for HTTPS the proxy sees only the CONNECT host:port.
81
+ #
82
+ # Build context is src/isolation/proxy (the image needs exactly two files):
83
+ # docker build -t quickstudy/egress-proxy -f images/egress-proxy/Dockerfile src/isolation/proxy
84
+ # or simply \`quickstudy images build\`, which builds this alongside the
85
+ # agent-runtime image.
86
+ #
87
+ # Config via env at \`docker run\`:
88
+ # QUICKSTUDY_EGRESS_ALLOWLIST comma-separated hosts / *.wildcards (required)
89
+ # QUICKSTUDY_EGRESS_PORT listen port (default 3128)
90
+
91
+ FROM oven/bun:1.3-slim
92
+
93
+ WORKDIR /app
94
+
95
+ COPY allowlist.ts server.ts ./proxy/
96
+
97
+ EXPOSE 3128
98
+
99
+ # Decisions (allowed + denied) stream to stdout as JSON lines; the harness
100
+ # reads them back with \`docker logs\` for per-attempt denial artifacts.
101
+ ENTRYPOINT ["bun", "/app/proxy/server.ts"]
102
+ `,
103
+ "images/mcp-proxy/Dockerfile": `# quickstudy mcp-proxy sidecar image.
104
+ #
105
+ # One per run, only when the runtime's mcpServers declare \`auth\`: attempt
106
+ # containers speak credential-free HTTP to this proxy, which forwards to the
107
+ # real MCP server injecting a Bearer access token it mints (and re-mints)
108
+ # from a refresh token (src/isolation/mcp-proxy/server.ts). Refresh tokens
109
+ # rotate on use; the current one is persisted inside the container at
110
+ # /run/quickstudy-mcp/ for the harness to read back at teardown.
111
+ #
112
+ # Build context is src/isolation/mcp-proxy (the image needs exactly one file):
113
+ # docker build -t quickstudy/mcp-proxy -f images/mcp-proxy/Dockerfile src/isolation/mcp-proxy
114
+ # or simply \`quickstudy images build\`, which builds this alongside the
115
+ # agent-runtime and egress-proxy images.
116
+ #
117
+ # Config via env at \`docker run\` (secrets arrive via --env-file, never argv):
118
+ # QUICKSTUDY_MCP_UPSTREAMS JSON {name: {url, tokenEndpoint, clientId, resource?}}
119
+ # QUICKSTUDY_MCP_REFRESH_TOKEN_<NAME> initial refresh token per server
120
+ # QUICKSTUDY_MCP_PORT listen port (default 8914)
121
+
122
+ FROM oven/bun:1.3-slim
123
+
124
+ WORKDIR /app
125
+
126
+ COPY server.ts ./mcp-proxy/
127
+
128
+ EXPOSE 8914
129
+
130
+ # Events (forwards + token refreshes) stream to stdout as JSON lines; token
131
+ # values never appear in them.
132
+ ENTRYPOINT ["bun", "/app/mcp-proxy/server.ts"]
133
+ `,
134
+ "src/isolation/proxy/allowlist.ts": `/**
135
+ * Egress allowlist: composition and matching.
136
+ *
137
+ * The allowlist composes from two layers, both recorded into the run's
138
+ * config_json for auditability:
139
+ * (1) harness defaults — each container agent adapter declares the LLM
140
+ * endpoints its CLI needs (\`egressHosts\` on the adapter);
141
+ * (2) runtime contribution — the experiment runtime's
142
+ * \`egressHosts(metadata)\` (product endpoints, package registries).
143
+ *
144
+ * Matching is deliberately narrow: exact hostnames and \`*.domain\` subdomain
145
+ * wildcards only — no path rules, no IP rules, no CIDR. Raw-IP CONNECT
146
+ * targets are refused outright (closing the resolve-then-dial DNS bypass),
147
+ * so IP literals in the allowlist itself are rejected at compose time.
148
+ */
149
+
150
+ /** An allowlist entry is invalid (IP literal, port, scheme, path, bad wildcard). */
151
+ export class AllowlistEntryError extends Error {
152
+ constructor(entry: string, problem: string) {
153
+ super(\`invalid egress allowlist entry "\${entry}": \${problem}\`);
154
+ this.name = "AllowlistEntryError";
155
+ }
156
+ }
157
+
158
+ const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
159
+
160
+ /**
161
+ * Normalize one host for matching: lowercase, strip a single trailing dot
162
+ * (DNS absolute form — \`evil.com.\` resolves identically to \`evil.com\` and
163
+ * must match the same allowlist entries, never bypass them).
164
+ */
165
+ export function normalizeHost(host: string): string {
166
+ const lowered = host.toLowerCase();
167
+ return lowered.endsWith(".") ? lowered.slice(0, -1) : lowered;
168
+ }
169
+
170
+ /**
171
+ * Is this host an IP literal (any form)? The proxy refuses raw-IP CONNECTs
172
+ * regardless of allowlist, so this is deliberately over-broad: dotted quads,
173
+ * anything with a colon (IPv6), bracketed literals, all-digit decimal forms
174
+ * (\`http://2130706433/\`), and 0x-prefixed hex forms all count.
175
+ */
176
+ export function isRawIpHost(host: string): boolean {
177
+ const h = normalizeHost(host);
178
+ if (h.length === 0) return true;
179
+ if (h.includes(":") || h.startsWith("[")) return true; // IPv6 / bracketed
180
+ if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(h)) return true; // dotted quad
181
+ if (/^\\d+$/.test(h)) return true; // decimal int form
182
+ if (/^0x[0-9a-f]+$/.test(h)) return true; // hex int form
183
+ if (/^[\\d.]+$/.test(h)) return true; // partial numeric forms (1.2.3, 1.2)
184
+ return false;
185
+ }
186
+
187
+ /** Validate one entry (exact host or \`*.domain\`); returns its normalized form. */
188
+ export function validateAllowlistEntry(entry: string): string {
189
+ const normalized = normalizeHost(entry.trim());
190
+ if (normalized.length === 0) throw new AllowlistEntryError(entry, "empty");
191
+ if (/[/?#\\s]/.test(normalized) || normalized.includes("://")) {
192
+ throw new AllowlistEntryError(entry, "must be a bare hostname (no scheme, path, or spaces)");
193
+ }
194
+ if (normalized.includes(":")) {
195
+ throw new AllowlistEntryError(entry, "must not carry a port (allowlisting is hostname-granularity)");
196
+ }
197
+ const body = normalized.startsWith("*.") ? normalized.slice(2) : normalized;
198
+ if (body.includes("*")) {
199
+ throw new AllowlistEntryError(entry, "wildcards are subdomain-only: a single leading \`*.\` label");
200
+ }
201
+ if (isRawIpHost(body)) {
202
+ throw new AllowlistEntryError(entry, "IP literals are refused by the proxy — allowlist hostnames only");
203
+ }
204
+ // Single-label hosts are legal: Docker-network service names ("origin")
205
+ // and localhost matter for tests and runtime-provisioned local services.
206
+ const labels = body.split(".");
207
+ if (!labels.every((label) => LABEL.test(label))) {
208
+ throw new AllowlistEntryError(entry, "not a valid hostname");
209
+ }
210
+ return normalized;
211
+ }
212
+
213
+ /**
214
+ * Does \`host\` match the allowlist? Exact entries match the whole hostname
215
+ * (case-insensitively, trailing dot stripped); \`*.domain\` entries match any
216
+ * subdomain of \`domain\` — at least one extra label, never \`domain\` itself
217
+ * (which is why runtimes list both \`example.com\` and \`*.example.com\`).
218
+ * IP literals never match anything.
219
+ */
220
+ export function hostAllowed(host: string, allowlist: readonly string[]): boolean {
221
+ const h = normalizeHost(host);
222
+ if (isRawIpHost(h)) return false;
223
+ for (const entry of allowlist) {
224
+ const e = normalizeHost(entry);
225
+ if (e.startsWith("*.")) {
226
+ const suffix = e.slice(1); // ".domain"
227
+ if (h.endsWith(suffix) && h.length > suffix.length) return true;
228
+ } else if (h === e) {
229
+ return true;
230
+ }
231
+ }
232
+ return false;
233
+ }
234
+
235
+ /**
236
+ * Parse a CONNECT authority-form target (\`host:port\`, \`[v6]:port\`) or a bare
237
+ * host. Returns undefined for garbage. The port defaults to 443 — CONNECT
238
+ * targets are overwhelmingly TLS.
239
+ */
240
+ export function parseHostPort(target: string): { host: string; port: number } | undefined {
241
+ const trimmed = target.trim();
242
+ if (trimmed.length === 0) return undefined;
243
+ let host: string;
244
+ let portText: string | undefined;
245
+ if (trimmed.startsWith("[")) {
246
+ // Bracketed IPv6 literal: [::1]:443 — kept intact so isRawIpHost sees it.
247
+ const end = trimmed.indexOf("]");
248
+ if (end === -1) return undefined;
249
+ host = trimmed.slice(0, end + 1);
250
+ const rest = trimmed.slice(end + 1);
251
+ if (rest.startsWith(":")) portText = rest.slice(1);
252
+ else if (rest !== "") return undefined;
253
+ } else {
254
+ const colon = trimmed.lastIndexOf(":");
255
+ // A second colon means an unbracketed IPv6 literal: keep whole as host.
256
+ if (colon !== -1 && trimmed.indexOf(":") === colon) {
257
+ host = trimmed.slice(0, colon);
258
+ portText = trimmed.slice(colon + 1);
259
+ } else {
260
+ host = trimmed;
261
+ }
262
+ }
263
+ if (host.length === 0) return undefined;
264
+ if (portText === undefined) return { host, port: 443 };
265
+ if (!/^\\d{1,5}$/.test(portText)) return undefined;
266
+ const port = Number(portText);
267
+ if (port < 1 || port > 65535) return undefined;
268
+ return { host, port };
269
+ }
270
+
271
+ /**
272
+ * Compose a validated allowlist from an explicit host list (the runner
273
+ * unions runtime- and adapter-declared hosts before calling). Validated,
274
+ * normalized, deduplicated, sorted — the sorted list is what lands in
275
+ * config_json, so two identical runs record identical allowlists.
276
+ */
277
+ export function composeAllowlistFromHosts(hosts: readonly string[]): string[] {
278
+ const entries = new Set<string>();
279
+ for (const host of hosts) entries.add(validateAllowlistEntry(host));
280
+ return [...entries].sort();
281
+ }
282
+ `,
283
+ "src/isolation/proxy/server.ts": `/**
284
+ * The egress allowlist proxy: a small HTTP CONNECT / absolute-form forward
285
+ * proxy (no squid dependency, no TLS interception).
286
+ *
287
+ * Attempt containers sit on an internal Docker network with no default
288
+ * route; this proxy straddles that network and the bridge, and the attempt
289
+ * env carries HTTP_PROXY/HTTPS_PROXY pointing here. For HTTPS the proxy sees
290
+ * only the CONNECT \`host:port\` — allowlist enforcement is hostname-granular,
291
+ * traffic is never decrypted. Deny-by-default; denials answer 403 naming the
292
+ * host; raw-IP CONNECT targets are refused outright (no DNS-bypass hole).
293
+ *
294
+ * Every decision (allowed AND denied) is emitted as one stable JSON event —
295
+ * to \`onEvent\` in-process, and as a JSON line on stdout when run as the
296
+ * sidecar entrypoint. Denials become the attempt's \`egress-denials.log\`
297
+ * artifact; zero allowed connections from an errored attempt is the
298
+ * \`egress_no_route\` signal (an agent whose HTTP stack ignored proxy env).
299
+ *
300
+ * Threat model (also in README): a MISBEHAVING agent, not a determined
301
+ * adversary — a hostile workload could still tunnel over an allowlisted
302
+ * host.
303
+ */
304
+
305
+ import { connect, createServer, type Server, type Socket } from "node:net";
306
+ import { hostAllowed, isRawIpHost, normalizeHost, parseHostPort, validateAllowlistEntry } from "./allowlist.ts";
307
+
308
+ /** One proxy decision. The line shape is stable — consumers parse it. */
309
+ export interface EgressEvent {
310
+ type: "egress";
311
+ /** ISO-8601 timestamp. */
312
+ ts: string;
313
+ /** Client IP (the attempt container's internal-network address). */
314
+ client: string;
315
+ /** "CONNECT" for tunnels, the HTTP method for plain forwards. */
316
+ method: string;
317
+ host: string;
318
+ port: number;
319
+ decision: "allowed" | "denied";
320
+ /** Present on denials only. */
321
+ reason?: "not_allowlisted" | "raw_ip" | "bad_request";
322
+ }
323
+
324
+ export interface EgressProxyOptions {
325
+ /** Validated at start; entries are exact hosts or \`*.domain\` wildcards. */
326
+ allowlist: readonly string[];
327
+ /** 0 (default) picks a random free port. Sidecar runs pass a fixed one. */
328
+ port?: number;
329
+ /** Bind address. Default 0.0.0.0 (the sidecar must accept from the internal network). */
330
+ hostname?: string;
331
+ /** Decision sink (both allowed and denied). */
332
+ onEvent?: (event: EgressEvent) => void;
333
+ }
334
+
335
+ export interface EgressProxyHandle {
336
+ port: number;
337
+ close(): Promise<void>;
338
+ }
339
+
340
+ /** How long a client may take to send its request head. */
341
+ const HEAD_TIMEOUT_MS = 30_000;
342
+ /** How long an upstream TCP connect may take. */
343
+ const CONNECT_TIMEOUT_MS = 20_000;
344
+ /** Request heads larger than this are dropped (nothing legitimate is close). */
345
+ const MAX_HEAD_BYTES = 32 * 1024;
346
+
347
+ /** Hop-by-hop headers never forwarded upstream on plain-HTTP forwards. */
348
+ const DROPPED_HEADERS = /^(proxy-connection|proxy-authorization|connection|keep-alive|te|upgrade|transfer-encoding)$/i;
349
+
350
+ function writeAndClose(socket: Socket, response: string): void {
351
+ socket.write(response, () => socket.end());
352
+ }
353
+
354
+ function httpError(status: number, statusText: string, body: string): string {
355
+ return (
356
+ \`HTTP/1.1 \${status} \${statusText}\\r\\n\` +
357
+ "Content-Type: text/plain; charset=utf-8\\r\\n" +
358
+ \`Content-Length: \${Buffer.byteLength(body)}\\r\\n\` +
359
+ "Connection: close\\r\\n\\r\\n" +
360
+ body
361
+ );
362
+ }
363
+
364
+ /** Start the proxy. Resolves once listening. */
365
+ export function startEgressProxy(options: EgressProxyOptions): Promise<EgressProxyHandle> {
366
+ // Fail fast on a malformed allowlist — a sidecar that silently allowed
367
+ // nothing (or crashed per-request) would fail every attempt wholesale.
368
+ const allowlist = options.allowlist.map(validateAllowlistEntry);
369
+ const onEvent = options.onEvent ?? (() => {});
370
+
371
+ const server: Server = createServer((client) => {
372
+ client.on("error", () => client.destroy());
373
+ const clientIp = client.remoteAddress ?? "unknown";
374
+ let buffered = Buffer.alloc(0);
375
+ let done = false;
376
+
377
+ const headTimer = setTimeout(() => {
378
+ if (!done) client.destroy();
379
+ }, HEAD_TIMEOUT_MS);
380
+
381
+ const emit = (
382
+ method: string,
383
+ host: string,
384
+ port: number,
385
+ decision: EgressEvent["decision"],
386
+ reason?: EgressEvent["reason"],
387
+ ): void => {
388
+ onEvent({
389
+ type: "egress",
390
+ ts: new Date().toISOString(),
391
+ client: clientIp,
392
+ method,
393
+ host,
394
+ port,
395
+ decision,
396
+ ...(reason ? { reason } : {}),
397
+ });
398
+ };
399
+
400
+ const onData = (chunk: Buffer): void => {
401
+ buffered = Buffer.concat([buffered, chunk]);
402
+ const headEnd = buffered.indexOf("\\r\\n\\r\\n");
403
+ if (headEnd === -1) {
404
+ if (buffered.length > MAX_HEAD_BYTES) {
405
+ done = true;
406
+ clearTimeout(headTimer);
407
+ writeAndClose(client, httpError(400, "Bad Request", "request head too large\\n"));
408
+ }
409
+ return;
410
+ }
411
+ done = true;
412
+ clearTimeout(headTimer);
413
+ client.removeListener("data", onData);
414
+ client.pause();
415
+
416
+ const head = buffered.subarray(0, headEnd).toString("latin1");
417
+ const remainder = buffered.subarray(headEnd + 4);
418
+ buffered = Buffer.alloc(0);
419
+ handleRequest(client, head, remainder, emit, allowlist);
420
+ };
421
+ client.on("data", onData);
422
+ });
423
+
424
+ // Track live client sockets so close() can sever open tunnels — a
425
+ // long-lived CONNECT tunnel would otherwise keep the server open forever.
426
+ const open = new Set<Socket>();
427
+ server.on("connection", (socket) => {
428
+ open.add(socket);
429
+ socket.on("close", () => open.delete(socket));
430
+ });
431
+
432
+ return new Promise((resolve, reject) => {
433
+ server.once("error", reject);
434
+ server.listen(options.port ?? 0, options.hostname ?? "0.0.0.0", () => {
435
+ const address = server.address();
436
+ const port = typeof address === "object" && address !== null ? address.port : 0;
437
+ resolve({
438
+ port,
439
+ close: () =>
440
+ new Promise<void>((done) => {
441
+ for (const socket of open) socket.destroy();
442
+ server.close(() => done());
443
+ }),
444
+ });
445
+ });
446
+ });
447
+ }
448
+
449
+ function handleRequest(
450
+ client: Socket,
451
+ head: string,
452
+ remainder: Buffer,
453
+ emit: (
454
+ method: string,
455
+ host: string,
456
+ port: number,
457
+ decision: EgressEvent["decision"],
458
+ reason?: EgressEvent["reason"],
459
+ ) => void,
460
+ allowlist: readonly string[],
461
+ ): void {
462
+ const lines = head.split("\\r\\n");
463
+ const requestLine = lines[0] ?? "";
464
+ const parts = requestLine.split(" ");
465
+ if (parts.length !== 3) {
466
+ emit(parts[0] ?? "?", "", 0, "denied", "bad_request");
467
+ writeAndClose(client, httpError(400, "Bad Request", \`malformed request line: "\${requestLine}"\\n\`));
468
+ return;
469
+ }
470
+ const [method, target] = parts as [string, string, string];
471
+
472
+ if (method === "CONNECT") {
473
+ handleConnect(client, target, remainder, emit, allowlist);
474
+ } else {
475
+ handleForward(client, method, target, lines.slice(1), remainder, emit, allowlist);
476
+ }
477
+ }
478
+
479
+ /** Shared allow/deny gate. Writes the 403 (naming the host) on denial. */
480
+ function gate(
481
+ client: Socket,
482
+ method: string,
483
+ host: string,
484
+ port: number,
485
+ emit: (
486
+ method: string,
487
+ host: string,
488
+ port: number,
489
+ decision: EgressEvent["decision"],
490
+ reason?: EgressEvent["reason"],
491
+ ) => void,
492
+ allowlist: readonly string[],
493
+ ): boolean {
494
+ const normalized = normalizeHost(host);
495
+ if (isRawIpHost(normalized)) {
496
+ emit(method, normalized, port, "denied", "raw_ip");
497
+ writeAndClose(
498
+ client,
499
+ httpError(403, "Forbidden", \`egress blocked: raw-IP targets are refused ("\${normalized}") — use a hostname\\n\`),
500
+ );
501
+ return false;
502
+ }
503
+ if (!hostAllowed(normalized, allowlist)) {
504
+ emit(method, normalized, port, "denied", "not_allowlisted");
505
+ writeAndClose(
506
+ client,
507
+ httpError(403, "Forbidden", \`egress blocked: "\${normalized}" is not on this run's egress allowlist\\n\`),
508
+ );
509
+ return false;
510
+ }
511
+ emit(method, normalized, port, "allowed");
512
+ return true;
513
+ }
514
+
515
+ /** Dial upstream with a connect timeout; invoke ready exactly once on success. */
516
+ function dialUpstream(client: Socket, host: string, port: number, ready: (upstream: Socket) => void): void {
517
+ const upstream = connect(port, host);
518
+ let established = false;
519
+ const connectTimer = setTimeout(() => {
520
+ if (!established) upstream.destroy(new Error("connect timeout"));
521
+ }, CONNECT_TIMEOUT_MS);
522
+ upstream.once("connect", () => {
523
+ established = true;
524
+ clearTimeout(connectTimer);
525
+ ready(upstream);
526
+ });
527
+ upstream.on("error", (err) => {
528
+ clearTimeout(connectTimer);
529
+ if (!established) {
530
+ writeAndClose(client, httpError(502, "Bad Gateway", \`upstream connect to \${host}:\${port} failed: \${err.message}\\n\`));
531
+ } else {
532
+ client.destroy();
533
+ }
534
+ });
535
+ }
536
+
537
+ /** Bidirectional tunnel wiring shared by CONNECT and plain forwards. */
538
+ function pipeBoth(client: Socket, upstream: Socket): void {
539
+ client.pipe(upstream);
540
+ upstream.pipe(client);
541
+ client.on("close", () => upstream.destroy());
542
+ upstream.on("close", () => client.destroy());
543
+ client.on("error", () => upstream.destroy());
544
+ }
545
+
546
+ /** CONNECT host:port — the HTTPS path. The tunnel is opaque to the proxy. */
547
+ function handleConnect(
548
+ client: Socket,
549
+ target: string,
550
+ remainder: Buffer,
551
+ emit: (
552
+ method: string,
553
+ host: string,
554
+ port: number,
555
+ decision: EgressEvent["decision"],
556
+ reason?: EgressEvent["reason"],
557
+ ) => void,
558
+ allowlist: readonly string[],
559
+ ): void {
560
+ const parsed = parseHostPort(target);
561
+ if (!parsed) {
562
+ emit("CONNECT", target, 0, "denied", "bad_request");
563
+ writeAndClose(client, httpError(400, "Bad Request", \`malformed CONNECT target: "\${target}"\\n\`));
564
+ return;
565
+ }
566
+ if (!gate(client, "CONNECT", parsed.host, parsed.port, emit, allowlist)) return;
567
+
568
+ dialUpstream(client, normalizeHost(parsed.host), parsed.port, (upstream) => {
569
+ client.write("HTTP/1.1 200 Connection Established\\r\\n\\r\\n");
570
+ // Bytes the client sent eagerly after its head (e.g. an early TLS hello).
571
+ if (remainder.length > 0) upstream.write(remainder);
572
+ pipeBoth(client, upstream);
573
+ client.resume();
574
+ });
575
+ }
576
+
577
+ /**
578
+ * Plain-HTTP forward: proxy clients send absolute-form URIs
579
+ * (\`GET http://host/path HTTP/1.1\`). The request is rewritten to origin form
580
+ * with \`Connection: close\` (one exchange per connection — simple beats
581
+ * keep-alive bookkeeping in a harness proxy).
582
+ */
583
+ function handleForward(
584
+ client: Socket,
585
+ method: string,
586
+ target: string,
587
+ headerLines: string[],
588
+ remainder: Buffer,
589
+ emit: (
590
+ method: string,
591
+ host: string,
592
+ port: number,
593
+ decision: EgressEvent["decision"],
594
+ reason?: EgressEvent["reason"],
595
+ ) => void,
596
+ allowlist: readonly string[],
597
+ ): void {
598
+ let url: URL;
599
+ try {
600
+ url = new URL(target);
601
+ } catch {
602
+ emit(method, target, 0, "denied", "bad_request");
603
+ writeAndClose(
604
+ client,
605
+ httpError(400, "Bad Request", \`expected an absolute-form proxy request URI, got "\${target}"\\n\`),
606
+ );
607
+ return;
608
+ }
609
+ if (url.protocol !== "http:") {
610
+ emit(method, url.hostname, 0, "denied", "bad_request");
611
+ writeAndClose(client, httpError(400, "Bad Request", \`unsupported scheme "\${url.protocol}" (https uses CONNECT)\\n\`));
612
+ return;
613
+ }
614
+ const host = url.hostname;
615
+ const port = url.port === "" ? 80 : Number(url.port);
616
+ if (!gate(client, method, host, port, emit, allowlist)) return;
617
+
618
+ const kept = headerLines.filter((line) => !DROPPED_HEADERS.test(line.split(":")[0] ?? ""));
619
+ const originForm = \`\${url.pathname}\${url.search}\`;
620
+ // Head assembled as lines and joined ONCE: an empty \`kept\` must not leave
621
+ // a blank line after the request line (that would end the header block
622
+ // early and push "Connection: close" into the body).
623
+ const rewritten = [\`\${method} \${originForm === "" ? "/" : originForm} HTTP/1.1\`, ...kept, "Connection: close", "", ""].join(
624
+ "\\r\\n",
625
+ );
626
+
627
+ dialUpstream(client, normalizeHost(host), port, (upstream) => {
628
+ upstream.write(rewritten);
629
+ if (remainder.length > 0) upstream.write(remainder);
630
+ pipeBoth(client, upstream);
631
+ client.resume();
632
+ });
633
+ }
634
+
635
+ /**
636
+ * Sidecar entrypoint (images/egress-proxy). Config via env:
637
+ * QUICKSTUDY_EGRESS_ALLOWLIST comma-separated entries (required)
638
+ * QUICKSTUDY_EGRESS_PORT listen port (default 3128)
639
+ * Every decision prints as one JSON line on stdout; the harness reads them
640
+ * back with \`docker logs\` for per-attempt denial artifacts.
641
+ */
642
+ if (import.meta.main) {
643
+ const rawList = process.env["QUICKSTUDY_EGRESS_ALLOWLIST"] ?? "";
644
+ const allowlist = rawList
645
+ .split(",")
646
+ .map((entry) => entry.trim())
647
+ .filter((entry) => entry !== "");
648
+ const port = Number(process.env["QUICKSTUDY_EGRESS_PORT"] ?? "3128");
649
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
650
+ console.error(\`egress-proxy: invalid QUICKSTUDY_EGRESS_PORT "\${process.env["QUICKSTUDY_EGRESS_PORT"]}"\`);
651
+ process.exit(1);
652
+ }
653
+ try {
654
+ const handle = await startEgressProxy({
655
+ allowlist,
656
+ port,
657
+ onEvent: (event) => console.log(JSON.stringify(event)),
658
+ });
659
+ console.log(JSON.stringify({ type: "listening", port: handle.port, allowlist }));
660
+ } catch (err) {
661
+ console.error(\`egress-proxy: failed to start: \${err instanceof Error ? err.message : String(err)}\`);
662
+ process.exit(1);
663
+ }
664
+ }
665
+ `,
666
+ "src/isolation/mcp-proxy/server.ts": `/**
667
+ * The MCP token-injecting proxy: attempt containers speak plain,
668
+ * credential-free HTTP to \`http://<sidecar>:<port>/<serverName>\`, and this
669
+ * proxy forwards each request to the real streamable-HTTP MCP server with
670
+ * \`Authorization: Bearer <access token>\` attached.
671
+ *
672
+ * Why it exists: some MCP servers only accept OAuth bearer tokens, which
673
+ * expire in minutes and are minted from a refresh token that ROTATES on
674
+ * every use. Neither belongs anywhere near an agent: a token in vendor
675
+ * config would land in workspace diffs, and concurrent per-attempt
676
+ * refreshes would invalidate each other. So the proxy owns the whole token
677
+ * lifecycle — one serialized refresher per upstream, access tokens cached
678
+ * until near expiry, one forced refresh + retry on an upstream 401.
679
+ *
680
+ * Rotation persistence: every rotated refresh token is written to
681
+ * \`<tokenStateDir>/<name>.refresh-token\` (0600). The harness reads that
682
+ * file back (docker exec) at teardown and persists it for the next run —
683
+ * losing it costs the operator a re-login, nothing worse.
684
+ *
685
+ * Like the egress proxy, this file is self-contained (the sidecar image
686
+ * copies it alone) and doubles as the container entrypoint. Events go to
687
+ * \`onEvent\` in-process and stdout JSON lines in the sidecar; token VALUES
688
+ * never appear in either.
689
+ */
690
+
691
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
692
+ import { join } from "node:path";
693
+
694
+ export interface McpUpstream {
695
+ /** The real MCP server URL requests are forwarded to. */
696
+ url: string;
697
+ /** OAuth token endpoint for the refresh grant. */
698
+ tokenEndpoint: string;
699
+ /** Public client id (token_endpoint_auth "none"); rides in the form body. */
700
+ clientId: string;
701
+ /** RFC 8707 resource indicator — REQUIRED by servers that audience-bind tokens. */
702
+ resource?: string;
703
+ }
704
+
705
+ /** One proxy decision or token event. The line shape is stable. */
706
+ export interface McpProxyEvent {
707
+ type: "mcp-proxy" | "mcp-token" | "listening";
708
+ ts: string;
709
+ server?: string;
710
+ /** mcp-proxy: upstream response status (or 0 when unreachable). */
711
+ status?: number;
712
+ method?: string;
713
+ /** mcp-token: whether the refresh response rotated the refresh token. */
714
+ rotated?: boolean;
715
+ error?: string;
716
+ port?: number;
717
+ }
718
+
719
+ export interface McpProxyOptions {
720
+ upstreams: Record<string, McpUpstream>;
721
+ /** Initial refresh tokens by server name (the only secrets handed in). */
722
+ refreshTokens: Record<string, string>;
723
+ /** 0 (default) picks a random free port. Sidecar runs pass a fixed one. */
724
+ port?: number;
725
+ /** Bind address. Default 0.0.0.0 (must accept from the attempt network). */
726
+ hostname?: string;
727
+ /** Where rotated refresh tokens are persisted, one file per server. */
728
+ tokenStateDir?: string;
729
+ onEvent?: (event: McpProxyEvent) => void;
730
+ }
731
+
732
+ export interface McpProxyHandle {
733
+ port: number;
734
+ close(): Promise<void>;
735
+ }
736
+
737
+ /** Access tokens are refreshed this many ms before their stated expiry. */
738
+ const EXPIRY_SLACK_MS = 30_000;
739
+ /** Assumed lifetime when the token response omits expires_in. */
740
+ const DEFAULT_EXPIRES_IN_S = 300;
741
+
742
+ /**
743
+ * Canonical server-name sanitizer, shared BY CONVENTION with
744
+ * src/isolation/docker.ts (env-var suffixes) — this file cannot import it.
745
+ */
746
+ export function sanitizeServerName(name: string): string {
747
+ return name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
748
+ }
749
+
750
+ /** Request headers forwarded upstream; everything else (esp. host) is dropped. */
751
+ const FORWARD_REQUEST_HEADERS = ["content-type", "accept", "mcp-session-id", "mcp-protocol-version", "last-event-id"];
752
+ /** Response headers forwarded back to the agent. */
753
+ const FORWARD_RESPONSE_HEADERS = ["content-type", "mcp-session-id", "mcp-protocol-version"];
754
+
755
+ interface TokenState {
756
+ refreshToken: string;
757
+ accessToken?: string;
758
+ expiresAtMs?: number;
759
+ /** Single-flight guard: concurrent requests share one refresh. */
760
+ inflight?: Promise<void>;
761
+ }
762
+
763
+ class RefreshError extends Error {
764
+ readonly oauthCode: string;
765
+
766
+ constructor(oauthCode: string) {
767
+ super(\`token refresh failed: \${oauthCode}\`);
768
+ this.oauthCode = oauthCode;
769
+ }
770
+ }
771
+
772
+ export function startMcpProxy(options: McpProxyOptions): McpProxyHandle {
773
+ const emit = (event: Omit<McpProxyEvent, "ts">): void => {
774
+ options.onEvent?.({ ...event, ts: new Date().toISOString() });
775
+ };
776
+ const states = new Map<string, TokenState>();
777
+ for (const name of Object.keys(options.upstreams)) {
778
+ const refreshToken = options.refreshTokens[name];
779
+ if (refreshToken === undefined || refreshToken === "") {
780
+ throw new Error(\`mcp-proxy: no refresh token provided for upstream "\${name}"\`);
781
+ }
782
+ states.set(name, { refreshToken });
783
+ }
784
+
785
+ const persistRefreshToken = (name: string, token: string): void => {
786
+ if (!options.tokenStateDir) return;
787
+ const path = join(options.tokenStateDir, \`\${sanitizeServerName(name)}.refresh-token\`);
788
+ writeFileSync(path, \`\${token}\\n\`, { encoding: "utf8", mode: 0o600 });
789
+ chmodSync(path, 0o600);
790
+ };
791
+ if (options.tokenStateDir) {
792
+ mkdirSync(options.tokenStateDir, { recursive: true, mode: 0o700 });
793
+ // Written at startup too, so teardown read-back never races the first
794
+ // rotation and always finds a file per server.
795
+ for (const [name, state] of states) persistRefreshToken(name, state.refreshToken);
796
+ }
797
+
798
+ async function refresh(name: string, upstream: McpUpstream, state: TokenState): Promise<void> {
799
+ const response = await fetch(upstream.tokenEndpoint, {
800
+ method: "POST",
801
+ headers: { "content-type": "application/x-www-form-urlencoded" },
802
+ body: new URLSearchParams({
803
+ grant_type: "refresh_token",
804
+ refresh_token: state.refreshToken,
805
+ client_id: upstream.clientId,
806
+ ...(upstream.resource !== undefined ? { resource: upstream.resource } : {}),
807
+ }).toString(),
808
+ });
809
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
810
+ if (!response.ok || typeof body["access_token"] !== "string") {
811
+ const code = typeof body["error"] === "string" ? body["error"] : \`http_\${response.status}\`;
812
+ throw new RefreshError(code);
813
+ }
814
+ state.accessToken = body["access_token"];
815
+ const expiresIn = typeof body["expires_in"] === "number" ? body["expires_in"] : DEFAULT_EXPIRES_IN_S;
816
+ state.expiresAtMs = Date.now() + expiresIn * 1000;
817
+ const rotated = typeof body["refresh_token"] === "string" && body["refresh_token"] !== state.refreshToken;
818
+ if (rotated) {
819
+ state.refreshToken = body["refresh_token"] as string;
820
+ persistRefreshToken(name, state.refreshToken);
821
+ }
822
+ emit({ type: "mcp-token", server: name, rotated });
823
+ }
824
+
825
+ async function ensureToken(name: string, upstream: McpUpstream, force = false): Promise<string> {
826
+ const state = states.get(name) as TokenState;
827
+ const fresh = state.accessToken !== undefined && state.expiresAtMs !== undefined && Date.now() < state.expiresAtMs - EXPIRY_SLACK_MS;
828
+ if (!force && fresh) return state.accessToken as string;
829
+ if (!state.inflight) {
830
+ state.inflight = refresh(name, upstream, state).finally(() => {
831
+ state.inflight = undefined;
832
+ });
833
+ }
834
+ await state.inflight;
835
+ return state.accessToken as string;
836
+ }
837
+
838
+ function forward(upstream: McpUpstream, request: Request, body: ArrayBuffer | undefined, accessToken: string): Promise<Response> {
839
+ const headers = new Headers({ authorization: \`Bearer \${accessToken}\` });
840
+ for (const header of FORWARD_REQUEST_HEADERS) {
841
+ const value = request.headers.get(header);
842
+ if (value !== null) headers.set(header, value);
843
+ }
844
+ return fetch(upstream.url, { method: request.method, headers, ...(body !== undefined ? { body } : {}) });
845
+ }
846
+
847
+ const server = Bun.serve({
848
+ hostname: options.hostname ?? "0.0.0.0",
849
+ port: options.port ?? 0,
850
+ idleTimeout: 0, // SSE streams are long-lived by design
851
+ async fetch(request) {
852
+ const name = new URL(request.url).pathname.replace(/^\\//, "");
853
+ const upstream = options.upstreams[name];
854
+ if (!upstream) return Response.json({ error: "unknown_server", server: name }, { status: 404 });
855
+
856
+ try {
857
+ // JSON-RPC requests are small — buffer ONCE (a request body cannot be
858
+ // re-read, and the 401 path forwards it twice). RESPONSES stream
859
+ // (SSE sessions are long-lived and unbounded).
860
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
861
+ let token = await ensureToken(name, upstream);
862
+ let response = await forward(upstream, request, body, token);
863
+ if (response.status === 401) {
864
+ // The cached token may have been revoked upstream — refresh once
865
+ // and retry once; a second 401 is the upstream's real answer.
866
+ token = await ensureToken(name, upstream, true);
867
+ response = await forward(upstream, request, body, token);
868
+ }
869
+ emit({ type: "mcp-proxy", server: name, method: request.method, status: response.status });
870
+ const headers = new Headers();
871
+ for (const header of FORWARD_RESPONSE_HEADERS) {
872
+ const value = response.headers.get(header);
873
+ if (value !== null) headers.set(header, value);
874
+ }
875
+ return new Response(response.body, { status: response.status, headers });
876
+ } catch (error) {
877
+ const detail = error instanceof RefreshError ? error.oauthCode : "upstream_unreachable";
878
+ emit({ type: "mcp-proxy", server: name, method: request.method, status: 0, error: detail });
879
+ return Response.json({ error: "mcp_proxy_error", detail }, { status: 502 });
880
+ }
881
+ },
882
+ });
883
+
884
+ emit({ type: "listening", port: server.port ?? options.port ?? 0 });
885
+ return {
886
+ port: server.port ?? options.port ?? 0,
887
+ close: async () => {
888
+ await server.stop(true);
889
+ },
890
+ };
891
+ }
892
+
893
+ // ---------------------------------------------------------------------------
894
+ // Sidecar entrypoint (images/mcp-proxy/Dockerfile). Config via env:
895
+ // QUICKSTUDY_MCP_UPSTREAMS JSON {name: McpUpstream} (required)
896
+ // QUICKSTUDY_MCP_REFRESH_TOKEN_<NAME> initial refresh token per server,
897
+ // NAME per sanitizeServerName
898
+ // QUICKSTUDY_MCP_PORT listen port (default 8914)
899
+ // QUICKSTUDY_MCP_TOKEN_STATE_DIR rotation files (default /run/quickstudy-mcp)
900
+ // ---------------------------------------------------------------------------
901
+
902
+ if (import.meta.main) {
903
+ const rawUpstreams = process.env["QUICKSTUDY_MCP_UPSTREAMS"];
904
+ if (!rawUpstreams) {
905
+ console.error("mcp-proxy: QUICKSTUDY_MCP_UPSTREAMS is required");
906
+ process.exit(1);
907
+ }
908
+ const upstreams = JSON.parse(rawUpstreams) as Record<string, McpUpstream>;
909
+ const refreshTokens: Record<string, string> = {};
910
+ for (const name of Object.keys(upstreams)) {
911
+ const token = process.env[\`QUICKSTUDY_MCP_REFRESH_TOKEN_\${sanitizeServerName(name)}\`];
912
+ if (!token) {
913
+ console.error(\`mcp-proxy: missing QUICKSTUDY_MCP_REFRESH_TOKEN_\${sanitizeServerName(name)}\`);
914
+ process.exit(1);
915
+ }
916
+ refreshTokens[name] = token;
917
+ }
918
+ startMcpProxy({
919
+ upstreams,
920
+ refreshTokens,
921
+ port: Number(process.env["QUICKSTUDY_MCP_PORT"] ?? 8914),
922
+ tokenStateDir: process.env["QUICKSTUDY_MCP_TOKEN_STATE_DIR"] ?? "/run/quickstudy-mcp",
923
+ onEvent: (event) => console.log(JSON.stringify(event)),
924
+ });
925
+ }
926
+ `,
927
+ };