@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,138 @@
1
+ /**
2
+ * Image resolution and building.
3
+ *
4
+ * The harness core owns exactly one image: `quickstudy/agent-runtime`
5
+ * (debian-slim + git + the pinned agent CLIs — see images/agent-runtime/).
6
+ * A runtime may declare per-framework images that layer toolchains on top;
7
+ * anything it does not declare resolves to the base image.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { IMAGE_CONTEXT_FILES } from "./image-contexts.generated.ts";
14
+
15
+ /** The base image tag the harness core owns and builds. */
16
+ export const AGENT_RUNTIME_IMAGE = "quickstudy/agent-runtime";
17
+
18
+ /** The egress-proxy sidecar image — also harness-owned. */
19
+ export const EGRESS_PROXY_IMAGE = "quickstudy/egress-proxy";
20
+
21
+ /** The MCP token-injecting proxy sidecar image — also harness-owned. */
22
+ export const MCP_PROXY_IMAGE = "quickstudy/mcp-proxy";
23
+
24
+ /**
25
+ * Locate the directory holding the harness image build contexts (`images/`
26
+ * and `src/isolation/`).
27
+ *
28
+ * Default: the embedded copies, materialized into a temp directory —
29
+ * `docker build` needs a real directory to send as context, and the harness
30
+ * carries one, so the same contexts build from source and from the compiled
31
+ * binary with no checkout anywhere. The ONLY override is an explicit
32
+ * `--images-dir` (when iterating on the Dockerfiles themselves, point it at
33
+ * a checkout — or regenerate: bun scripts/generate-image-contexts.ts).
34
+ */
35
+ export function resolveImagesRoot(override?: string): string {
36
+ if (override) return resolve(override);
37
+ return materializeEmbeddedContexts();
38
+ }
39
+
40
+ /**
41
+ * Write the binary-embedded build contexts into a temp directory mirroring
42
+ * the repo layout, so `imagePaths()` resolves against it unchanged. One
43
+ * directory per process: the three image builds of `images build` share it.
44
+ */
45
+ let embeddedRoot: string | undefined;
46
+ export function materializeEmbeddedContexts(): string {
47
+ if (embeddedRoot !== undefined) return embeddedRoot;
48
+ const root = mkdtempSync(join(tmpdir(), "quickstudy-image-contexts-"));
49
+ for (const [rel, contents] of Object.entries(IMAGE_CONTEXT_FILES)) {
50
+ const abs = join(root, rel);
51
+ mkdirSync(dirname(abs), { recursive: true });
52
+ writeFileSync(abs, contents);
53
+ }
54
+ embeddedRoot = root;
55
+ return root;
56
+ }
57
+
58
+ /** The build contexts, resolved against a given harness root. */
59
+ function imagePaths(root: string) {
60
+ return {
61
+ /** Base-image Dockerfile directory (context = the directory). */
62
+ agentRuntimeBuildDir: join(root, "images", "agent-runtime"),
63
+ /**
64
+ * The egress-proxy Dockerfile and its build CONTEXT (just the proxy
65
+ * sources — the image copies two files, so shipping the whole repo to the
66
+ * daemon on every build would be pure waste).
67
+ */
68
+ egressProxyDockerfile: join(root, "images", "egress-proxy", "Dockerfile"),
69
+ egressProxyContext: join(root, "src", "isolation", "proxy"),
70
+ /** The mcp-proxy Dockerfile and its build context (its single source file). */
71
+ mcpProxyDockerfile: join(root, "images", "mcp-proxy", "Dockerfile"),
72
+ mcpProxyContext: join(root, "src", "isolation", "mcp-proxy"),
73
+ };
74
+ }
75
+
76
+ /** A build-context-missing error that points at the fix. */
77
+ function missingContext(what: string, path: string): Error {
78
+ return new Error(
79
+ `${what} not found at ${path}.\n` +
80
+ "Point --images-dir at a quickstudy checkout, or omit it — " +
81
+ "without the flag the binary builds from its own embedded contexts.",
82
+ );
83
+ }
84
+
85
+ /** Options common to the base-image builds. */
86
+ export interface BuildImageOptions {
87
+ /** Pass `docker build --pull` to refresh base layers. */
88
+ pull?: boolean;
89
+ /** Override where the build contexts are read from (see {@link resolveImagesRoot}). */
90
+ imagesRoot?: string;
91
+ }
92
+
93
+ /**
94
+ * Build the agent-runtime base image (`quickstudy images build`). Streams
95
+ * docker's own progress output straight through — this is an interactive
96
+ * developer command, not an attempt-time operation.
97
+ */
98
+ export async function buildAgentRuntimeImage(opts: BuildImageOptions = {}): Promise<number> {
99
+ const { agentRuntimeBuildDir } = imagePaths(resolveImagesRoot(opts.imagesRoot));
100
+ if (!existsSync(join(agentRuntimeBuildDir, "Dockerfile"))) {
101
+ throw missingContext("agent-runtime Dockerfile", join(agentRuntimeBuildDir, "Dockerfile"));
102
+ }
103
+ const args = ["build", "-t", AGENT_RUNTIME_IMAGE];
104
+ if (opts.pull) args.push("--pull");
105
+ args.push(agentRuntimeBuildDir);
106
+ const proc = Bun.spawn(["docker", ...args], { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
107
+ return proc.exited;
108
+ }
109
+
110
+ /**
111
+ * Build the egress-proxy sidecar image (also part of `quickstudy images
112
+ * build`). The build context is src/isolation/proxy — the Dockerfile lives
113
+ * in images/egress-proxy/ next to its sibling, passed via -f.
114
+ */
115
+ export async function buildEgressProxyImage(opts: BuildImageOptions = {}): Promise<number> {
116
+ const { egressProxyDockerfile, egressProxyContext } = imagePaths(resolveImagesRoot(opts.imagesRoot));
117
+ if (!existsSync(egressProxyDockerfile)) {
118
+ throw missingContext("egress-proxy Dockerfile", egressProxyDockerfile);
119
+ }
120
+ const args = ["build", "-t", EGRESS_PROXY_IMAGE, "-f", egressProxyDockerfile];
121
+ if (opts.pull) args.push("--pull");
122
+ args.push(egressProxyContext);
123
+ const proc = Bun.spawn(["docker", ...args], { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
124
+ return proc.exited;
125
+ }
126
+
127
+ /** Build the mcp-proxy sidecar image (also part of `quickstudy images build`). */
128
+ export async function buildMcpProxyImage(opts: BuildImageOptions = {}): Promise<number> {
129
+ const { mcpProxyDockerfile, mcpProxyContext } = imagePaths(resolveImagesRoot(opts.imagesRoot));
130
+ if (!existsSync(mcpProxyDockerfile)) {
131
+ throw missingContext("mcp-proxy Dockerfile", mcpProxyDockerfile);
132
+ }
133
+ const args = ["build", "-t", MCP_PROXY_IMAGE, "-f", mcpProxyDockerfile];
134
+ if (opts.pull) args.push("--pull");
135
+ args.push(mcpProxyContext);
136
+ const proc = Bun.spawn(["docker", ...args], { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
137
+ return proc.exited;
138
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The MCP token-injecting proxy: attempt containers speak plain,
3
+ * credential-free HTTP to `http://<sidecar>:<port>/<serverName>`, and this
4
+ * proxy forwards each request to the real streamable-HTTP MCP server with
5
+ * `Authorization: Bearer <access token>` attached.
6
+ *
7
+ * Why it exists: some MCP servers only accept OAuth bearer tokens, which
8
+ * expire in minutes and are minted from a refresh token that ROTATES on
9
+ * every use. Neither belongs anywhere near an agent: a token in vendor
10
+ * config would land in workspace diffs, and concurrent per-attempt
11
+ * refreshes would invalidate each other. So the proxy owns the whole token
12
+ * lifecycle — one serialized refresher per upstream, access tokens cached
13
+ * until near expiry, one forced refresh + retry on an upstream 401.
14
+ *
15
+ * Rotation persistence: every rotated refresh token is written to
16
+ * `<tokenStateDir>/<name>.refresh-token` (0600). The harness reads that
17
+ * file back (docker exec) at teardown and persists it for the next run —
18
+ * losing it costs the operator a re-login, nothing worse.
19
+ *
20
+ * Like the egress proxy, this file is self-contained (the sidecar image
21
+ * copies it alone) and doubles as the container entrypoint. Events go to
22
+ * `onEvent` in-process and stdout JSON lines in the sidecar; token VALUES
23
+ * never appear in either.
24
+ */
25
+
26
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+
29
+ export interface McpUpstream {
30
+ /** The real MCP server URL requests are forwarded to. */
31
+ url: string;
32
+ /** OAuth token endpoint for the refresh grant. */
33
+ tokenEndpoint: string;
34
+ /** Public client id (token_endpoint_auth "none"); rides in the form body. */
35
+ clientId: string;
36
+ /** RFC 8707 resource indicator — REQUIRED by servers that audience-bind tokens. */
37
+ resource?: string;
38
+ }
39
+
40
+ /** One proxy decision or token event. The line shape is stable. */
41
+ export interface McpProxyEvent {
42
+ type: "mcp-proxy" | "mcp-token" | "listening";
43
+ ts: string;
44
+ server?: string;
45
+ /** mcp-proxy: upstream response status (or 0 when unreachable). */
46
+ status?: number;
47
+ method?: string;
48
+ /** mcp-token: whether the refresh response rotated the refresh token. */
49
+ rotated?: boolean;
50
+ error?: string;
51
+ port?: number;
52
+ }
53
+
54
+ export interface McpProxyOptions {
55
+ upstreams: Record<string, McpUpstream>;
56
+ /** Initial refresh tokens by server name (the only secrets handed in). */
57
+ refreshTokens: Record<string, string>;
58
+ /** 0 (default) picks a random free port. Sidecar runs pass a fixed one. */
59
+ port?: number;
60
+ /** Bind address. Default 0.0.0.0 (must accept from the attempt network). */
61
+ hostname?: string;
62
+ /** Where rotated refresh tokens are persisted, one file per server. */
63
+ tokenStateDir?: string;
64
+ onEvent?: (event: McpProxyEvent) => void;
65
+ }
66
+
67
+ export interface McpProxyHandle {
68
+ port: number;
69
+ close(): Promise<void>;
70
+ }
71
+
72
+ /** Access tokens are refreshed this many ms before their stated expiry. */
73
+ const EXPIRY_SLACK_MS = 30_000;
74
+ /** Assumed lifetime when the token response omits expires_in. */
75
+ const DEFAULT_EXPIRES_IN_S = 300;
76
+
77
+ /**
78
+ * Canonical server-name sanitizer, shared BY CONVENTION with
79
+ * src/isolation/docker.ts (env-var suffixes) — this file cannot import it.
80
+ */
81
+ export function sanitizeServerName(name: string): string {
82
+ return name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
83
+ }
84
+
85
+ /** Request headers forwarded upstream; everything else (esp. host) is dropped. */
86
+ const FORWARD_REQUEST_HEADERS = ["content-type", "accept", "mcp-session-id", "mcp-protocol-version", "last-event-id"];
87
+ /** Response headers forwarded back to the agent. */
88
+ const FORWARD_RESPONSE_HEADERS = ["content-type", "mcp-session-id", "mcp-protocol-version"];
89
+
90
+ interface TokenState {
91
+ refreshToken: string;
92
+ accessToken?: string;
93
+ expiresAtMs?: number;
94
+ /** Single-flight guard: concurrent requests share one refresh. */
95
+ inflight?: Promise<void>;
96
+ }
97
+
98
+ class RefreshError extends Error {
99
+ readonly oauthCode: string;
100
+
101
+ constructor(oauthCode: string) {
102
+ super(`token refresh failed: ${oauthCode}`);
103
+ this.oauthCode = oauthCode;
104
+ }
105
+ }
106
+
107
+ export function startMcpProxy(options: McpProxyOptions): McpProxyHandle {
108
+ const emit = (event: Omit<McpProxyEvent, "ts">): void => {
109
+ options.onEvent?.({ ...event, ts: new Date().toISOString() });
110
+ };
111
+ const states = new Map<string, TokenState>();
112
+ for (const name of Object.keys(options.upstreams)) {
113
+ const refreshToken = options.refreshTokens[name];
114
+ if (refreshToken === undefined || refreshToken === "") {
115
+ throw new Error(`mcp-proxy: no refresh token provided for upstream "${name}"`);
116
+ }
117
+ states.set(name, { refreshToken });
118
+ }
119
+
120
+ const persistRefreshToken = (name: string, token: string): void => {
121
+ if (!options.tokenStateDir) return;
122
+ const path = join(options.tokenStateDir, `${sanitizeServerName(name)}.refresh-token`);
123
+ writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
124
+ chmodSync(path, 0o600);
125
+ };
126
+ if (options.tokenStateDir) {
127
+ mkdirSync(options.tokenStateDir, { recursive: true, mode: 0o700 });
128
+ // Written at startup too, so teardown read-back never races the first
129
+ // rotation and always finds a file per server.
130
+ for (const [name, state] of states) persistRefreshToken(name, state.refreshToken);
131
+ }
132
+
133
+ async function refresh(name: string, upstream: McpUpstream, state: TokenState): Promise<void> {
134
+ const response = await fetch(upstream.tokenEndpoint, {
135
+ method: "POST",
136
+ headers: { "content-type": "application/x-www-form-urlencoded" },
137
+ body: new URLSearchParams({
138
+ grant_type: "refresh_token",
139
+ refresh_token: state.refreshToken,
140
+ client_id: upstream.clientId,
141
+ ...(upstream.resource !== undefined ? { resource: upstream.resource } : {}),
142
+ }).toString(),
143
+ });
144
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
145
+ if (!response.ok || typeof body["access_token"] !== "string") {
146
+ const code = typeof body["error"] === "string" ? body["error"] : `http_${response.status}`;
147
+ throw new RefreshError(code);
148
+ }
149
+ state.accessToken = body["access_token"];
150
+ const expiresIn = typeof body["expires_in"] === "number" ? body["expires_in"] : DEFAULT_EXPIRES_IN_S;
151
+ state.expiresAtMs = Date.now() + expiresIn * 1000;
152
+ const rotated = typeof body["refresh_token"] === "string" && body["refresh_token"] !== state.refreshToken;
153
+ if (rotated) {
154
+ state.refreshToken = body["refresh_token"] as string;
155
+ persistRefreshToken(name, state.refreshToken);
156
+ }
157
+ emit({ type: "mcp-token", server: name, rotated });
158
+ }
159
+
160
+ async function ensureToken(name: string, upstream: McpUpstream, force = false): Promise<string> {
161
+ const state = states.get(name) as TokenState;
162
+ const fresh = state.accessToken !== undefined && state.expiresAtMs !== undefined && Date.now() < state.expiresAtMs - EXPIRY_SLACK_MS;
163
+ if (!force && fresh) return state.accessToken as string;
164
+ if (!state.inflight) {
165
+ state.inflight = refresh(name, upstream, state).finally(() => {
166
+ state.inflight = undefined;
167
+ });
168
+ }
169
+ await state.inflight;
170
+ return state.accessToken as string;
171
+ }
172
+
173
+ function forward(upstream: McpUpstream, request: Request, body: ArrayBuffer | undefined, accessToken: string): Promise<Response> {
174
+ const headers = new Headers({ authorization: `Bearer ${accessToken}` });
175
+ for (const header of FORWARD_REQUEST_HEADERS) {
176
+ const value = request.headers.get(header);
177
+ if (value !== null) headers.set(header, value);
178
+ }
179
+ return fetch(upstream.url, { method: request.method, headers, ...(body !== undefined ? { body } : {}) });
180
+ }
181
+
182
+ const server = Bun.serve({
183
+ hostname: options.hostname ?? "0.0.0.0",
184
+ port: options.port ?? 0,
185
+ idleTimeout: 0, // SSE streams are long-lived by design
186
+ async fetch(request) {
187
+ const name = new URL(request.url).pathname.replace(/^\//, "");
188
+ const upstream = options.upstreams[name];
189
+ if (!upstream) return Response.json({ error: "unknown_server", server: name }, { status: 404 });
190
+
191
+ try {
192
+ // JSON-RPC requests are small — buffer ONCE (a request body cannot be
193
+ // re-read, and the 401 path forwards it twice). RESPONSES stream
194
+ // (SSE sessions are long-lived and unbounded).
195
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
196
+ let token = await ensureToken(name, upstream);
197
+ let response = await forward(upstream, request, body, token);
198
+ if (response.status === 401) {
199
+ // The cached token may have been revoked upstream — refresh once
200
+ // and retry once; a second 401 is the upstream's real answer.
201
+ token = await ensureToken(name, upstream, true);
202
+ response = await forward(upstream, request, body, token);
203
+ }
204
+ emit({ type: "mcp-proxy", server: name, method: request.method, status: response.status });
205
+ const headers = new Headers();
206
+ for (const header of FORWARD_RESPONSE_HEADERS) {
207
+ const value = response.headers.get(header);
208
+ if (value !== null) headers.set(header, value);
209
+ }
210
+ return new Response(response.body, { status: response.status, headers });
211
+ } catch (error) {
212
+ const detail = error instanceof RefreshError ? error.oauthCode : "upstream_unreachable";
213
+ emit({ type: "mcp-proxy", server: name, method: request.method, status: 0, error: detail });
214
+ return Response.json({ error: "mcp_proxy_error", detail }, { status: 502 });
215
+ }
216
+ },
217
+ });
218
+
219
+ emit({ type: "listening", port: server.port ?? options.port ?? 0 });
220
+ return {
221
+ port: server.port ?? options.port ?? 0,
222
+ close: async () => {
223
+ await server.stop(true);
224
+ },
225
+ };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Sidecar entrypoint (images/mcp-proxy/Dockerfile). Config via env:
230
+ // QUICKSTUDY_MCP_UPSTREAMS JSON {name: McpUpstream} (required)
231
+ // QUICKSTUDY_MCP_REFRESH_TOKEN_<NAME> initial refresh token per server,
232
+ // NAME per sanitizeServerName
233
+ // QUICKSTUDY_MCP_PORT listen port (default 8914)
234
+ // QUICKSTUDY_MCP_TOKEN_STATE_DIR rotation files (default /run/quickstudy-mcp)
235
+ // ---------------------------------------------------------------------------
236
+
237
+ if (import.meta.main) {
238
+ const rawUpstreams = process.env["QUICKSTUDY_MCP_UPSTREAMS"];
239
+ if (!rawUpstreams) {
240
+ console.error("mcp-proxy: QUICKSTUDY_MCP_UPSTREAMS is required");
241
+ process.exit(1);
242
+ }
243
+ const upstreams = JSON.parse(rawUpstreams) as Record<string, McpUpstream>;
244
+ const refreshTokens: Record<string, string> = {};
245
+ for (const name of Object.keys(upstreams)) {
246
+ const token = process.env[`QUICKSTUDY_MCP_REFRESH_TOKEN_${sanitizeServerName(name)}`];
247
+ if (!token) {
248
+ console.error(`mcp-proxy: missing QUICKSTUDY_MCP_REFRESH_TOKEN_${sanitizeServerName(name)}`);
249
+ process.exit(1);
250
+ }
251
+ refreshTokens[name] = token;
252
+ }
253
+ startMcpProxy({
254
+ upstreams,
255
+ refreshTokens,
256
+ port: Number(process.env["QUICKSTUDY_MCP_PORT"] ?? 8914),
257
+ tokenStateDir: process.env["QUICKSTUDY_MCP_TOKEN_STATE_DIR"] ?? "/run/quickstudy-mcp",
258
+ onEvent: (event) => console.log(JSON.stringify(event)),
259
+ });
260
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * MCP auth-proxy wiring: the pure glue between runtime-declared
3
+ * `McpServerConfig.auth` and the mcp-proxy sidecar (mcp-proxy/server.ts).
4
+ *
5
+ * Credentials never ride in runtime config — `auth.credentialsFile` names an
6
+ * env-format file the owning runtime's login tooling maintains. This module
7
+ * reads it (fail-fast with the operator's fix), rewrites the server map
8
+ * agents see (proxy URL in, real URL + auth out), and persists rotated
9
+ * refresh tokens back after the run — rotation means the on-disk token dies
10
+ * the moment the sidecar refreshes, so skipping persistence would cost the
11
+ * operator a re-login every run.
12
+ */
13
+
14
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { sanitizeServerName, type McpUpstream } from "./mcp-proxy/server.ts";
16
+
17
+ /**
18
+ * Plain OAuth refresh-token auth config for an MCP server — the v3 shape.
19
+ * No secret values here: `credentialsFile` names an env-format file the
20
+ * owning runtime's login tooling maintains (0600, gitignored).
21
+ */
22
+ export interface McpServerAuthConfig {
23
+ kind: "oauth-refresh";
24
+ /** OAuth token endpoint for the refresh grant. */
25
+ tokenEndpoint: string;
26
+ /** RFC 8707 resource indicator, when the server audience-binds tokens. */
27
+ resource?: string;
28
+ /** Absolute path to the env-format credentials file. */
29
+ credentialsFile: string;
30
+ /** Key in that file holding the OAuth client id. */
31
+ clientIdKey: string;
32
+ /** Key in that file holding the current refresh token. */
33
+ refreshTokenKey: string;
34
+ }
35
+
36
+ /** Plain `{url, auth}` MCP server config declared by experiment runtimes. */
37
+ export interface McpServerConfig {
38
+ url: string;
39
+ auth?: McpServerAuthConfig;
40
+ }
41
+
42
+ /** Everything the sidecar needs: config (loggable) + secrets (never logged). */
43
+ export interface McpAuthMaterial {
44
+ upstreams: Record<string, McpUpstream>;
45
+ /** Refresh tokens by server name — env-file-only, never argv or logs. */
46
+ refreshTokens: Record<string, string>;
47
+ }
48
+
49
+ /** Servers whose config carries an `auth` block. */
50
+ export function authedMcpServers<T extends McpServerConfig>(servers: Record<string, T>): Record<string, T> {
51
+ return Object.fromEntries(Object.entries(servers).filter(([, spec]) => spec.auth !== undefined));
52
+ }
53
+
54
+ function parseEnvFile(content: string): Map<string, string> {
55
+ const values = new Map<string, string>();
56
+ for (const line of content.split("\n")) {
57
+ const eq = line.indexOf("=");
58
+ if (eq > 0 && !line.trimStart().startsWith("#")) values.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
59
+ }
60
+ return values;
61
+ }
62
+
63
+ /**
64
+ * Read every authed server's credentials file into sidecar material.
65
+ * Failures are fail-fast and actionable: a missing file or key means the
66
+ * operator has not run the owning login tooling (or its token store
67
+ * moved) — starting the run would burn attempts on guaranteed auth errors.
68
+ */
69
+ export function readMcpAuthMaterialFromConfig(servers: Record<string, McpServerConfig>): McpAuthMaterial {
70
+ const upstreams: Record<string, McpUpstream> = {};
71
+ const refreshTokens: Record<string, string> = {};
72
+ const sanitized = new Map<string, string>();
73
+
74
+ for (const [name, spec] of Object.entries(servers)) {
75
+ const auth = spec.auth;
76
+ if (!auth) continue;
77
+
78
+ const clash = sanitized.get(sanitizeServerName(name));
79
+ if (clash !== undefined) {
80
+ throw new Error(`mcp servers "${clash}" and "${name}" collide after name sanitization — rename one`);
81
+ }
82
+ sanitized.set(sanitizeServerName(name), name);
83
+
84
+ if (!existsSync(auth.credentialsFile)) {
85
+ throw new Error(
86
+ `mcp server "${name}": credentials file ${auth.credentialsFile} does not exist — ` +
87
+ `run the owning runtime's MCP login tooling to create it, or deselect the MCP-treatment experiment`,
88
+ );
89
+ }
90
+ const values = parseEnvFile(readFileSync(auth.credentialsFile, "utf8"));
91
+ const clientId = values.get(auth.clientIdKey);
92
+ const refreshToken = values.get(auth.refreshTokenKey);
93
+ if (!clientId || !refreshToken) {
94
+ throw new Error(
95
+ `mcp server "${name}": ${auth.credentialsFile} lacks ${auth.clientIdKey} and/or ${auth.refreshTokenKey} — ` +
96
+ `re-run the owning runtime's MCP login tooling`,
97
+ );
98
+ }
99
+ upstreams[name] = {
100
+ url: spec.url,
101
+ tokenEndpoint: auth.tokenEndpoint,
102
+ clientId,
103
+ ...(auth.resource !== undefined ? { resource: auth.resource } : {}),
104
+ };
105
+ refreshTokens[name] = refreshToken;
106
+ }
107
+ return { upstreams, refreshTokens };
108
+ }
109
+
110
+ /**
111
+ * The server map agents actually see: authed servers point at the sidecar
112
+ * (`<proxyBase>/<name>`), auth blocks are stripped everywhere — adapters
113
+ * render vendor config from this and must never carry credential material.
114
+ */
115
+ export function rewriteMcpServerConfigs(
116
+ servers: Record<string, McpServerConfig>,
117
+ proxyBase: string,
118
+ ): Record<string, McpServerConfig> {
119
+ return Object.fromEntries(
120
+ Object.entries(servers).map(([name, spec]) => [
121
+ name,
122
+ { url: spec.auth !== undefined ? `${proxyBase}/${encodeURIComponent(name)}` : spec.url },
123
+ ]),
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Write a rotated refresh token back to its credentials file, preserving
129
+ * every other line. 0600 like the login tooling that created it.
130
+ */
131
+ export function persistRotatedRefreshToken(spec: McpServerConfig, rotatedToken: string): void {
132
+ const auth = spec.auth;
133
+ if (!auth) return;
134
+ const lines = existsSync(auth.credentialsFile) ? readFileSync(auth.credentialsFile, "utf8").split("\n") : [];
135
+ const prefix = `${auth.refreshTokenKey}=`;
136
+ const index = lines.findIndex((line) => line.startsWith(prefix));
137
+ if (index >= 0) lines[index] = `${prefix}${rotatedToken}`;
138
+ else {
139
+ while (lines.at(-1) === "") lines.pop();
140
+ lines.push(`${prefix}${rotatedToken}`, "");
141
+ }
142
+ writeFileSync(auth.credentialsFile, lines.join("\n"), { encoding: "utf8", mode: 0o600 });
143
+ chmodSync(auth.credentialsFile, 0o600);
144
+ }