@workos/quickstudy 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/examples/harbor-notes/README.md +40 -0
- package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
- package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
- package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
- package/examples/harbor-notes/experiments/scripted.ts +6 -0
- package/examples/harbor-notes/package.json +6 -0
- package/examples/harbor-notes/quickstudy.identity.json +1 -0
- package/examples/harbor-notes/runtime.ts +48 -0
- package/examples/harbor-notes/semantic-example.ts +21 -0
- package/images/agent-runtime/Dockerfile +58 -0
- package/images/egress-proxy/Dockerfile +28 -0
- package/images/mcp-proxy/Dockerfile +30 -0
- package/package.json +53 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +107 -0
- package/src/adapters/echo.ts +57 -0
- package/src/adapters/parse.ts +117 -0
- package/src/adapters/types.ts +152 -0
- package/src/build-info.generated.ts +12 -0
- package/src/cli.ts +787 -0
- package/src/completeness.ts +104 -0
- package/src/diagnose/excerpt.ts +106 -0
- package/src/diagnose/prompt.ts +175 -0
- package/src/diagnose/render.ts +55 -0
- package/src/diagnose/run.ts +290 -0
- package/src/diagnose/select.ts +110 -0
- package/src/diagnose/types.ts +88 -0
- package/src/evals/discovery.ts +173 -0
- package/src/evals/prompt.ts +190 -0
- package/src/evals/result.ts +10 -0
- package/src/evals/types.ts +115 -0
- package/src/execution-policy.ts +71 -0
- package/src/experiments/discovery.ts +76 -0
- package/src/experiments/groups.ts +119 -0
- package/src/experiments/types.ts +116 -0
- package/src/export-types.ts +127 -0
- package/src/export.ts +381 -0
- package/src/hash.ts +74 -0
- package/src/identity-diff.ts +30 -0
- package/src/ids.ts +30 -0
- package/src/index.ts +58 -0
- package/src/isolation/docker.ts +639 -0
- package/src/isolation/image-contexts.generated.ts +927 -0
- package/src/isolation/images.ts +138 -0
- package/src/isolation/mcp-proxy/server.ts +260 -0
- package/src/isolation/mcp.ts +144 -0
- package/src/isolation/proxy/allowlist.ts +148 -0
- package/src/isolation/proxy/server.ts +382 -0
- package/src/llm.ts +132 -0
- package/src/manifest.ts +228 -0
- package/src/model-identity.ts +12 -0
- package/src/plan.ts +55 -0
- package/src/probe.ts +426 -0
- package/src/report/pass-at-k.ts +76 -0
- package/src/report/report.ts +731 -0
- package/src/runner/context.ts +96 -0
- package/src/runner/deadline.ts +37 -0
- package/src/runner/execute.ts +992 -0
- package/src/runner/run-lock.ts +32 -0
- package/src/runner/scheduler.ts +62 -0
- package/src/runner/score-worker.ts +107 -0
- package/src/runner/scorer-worker.ts +61 -0
- package/src/runtime/types.ts +89 -0
- package/src/secrets.ts +151 -0
- package/src/semantic.ts +185 -0
- package/src/serve.ts +52 -0
- package/src/source-identity.ts +76 -0
- package/src/store/artifacts.ts +146 -0
- package/src/store/db.ts +318 -0
- package/src/store/schema.ts +39 -0
- package/src/surface-usage.ts +297 -0
- package/src/ui-bundle.generated.ts +12 -0
- package/ui/dist/index.html +32 -0
|
@@ -0,0 +1,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
|
+
};
|