@yolo-labs/yolobridge 0.1.0 → 0.2.0

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.
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Local MCP access for the attached agent (docs/YOLOBRIDGE_PLAN.md, "Local
3
+ * MCP access for the attached agent"). Ports `containers/services/container-api/mcp-proxy.js`'s
4
+ * mint-and-inject logic to run on the OPERATOR'S OWN MACHINE instead of
5
+ * in-pod: a tiny HTTP server bound to `127.0.0.1` ONLY (never `0.0.0.0` — it
6
+ * holds a real delegated token in memory), started on attach, that mints a
7
+ * short-lived scoped MCP token via the same public `POST /v1/mcp/tokens`
8
+ * endpoint pod agents already use, injects it into outgoing `tools/call`
9
+ * requests exactly the way `mcp-proxy.js`'s `injectToken()` does, and
10
+ * forwards to the real, already-public `yolo-studio-mcp` `/mcp` endpoint
11
+ * (verified live 2026-08-24: `services.yolo.studio` / `services-staging.yolo.studio`,
12
+ * both real `406`s to an unauthenticated MCP `initialize` call, not DNS
13
+ * failures — see the plan doc for how this was confirmed instead of assumed).
14
+ *
15
+ * Scope is FULL, workspace-wide parity with a normal pod-hosted agent tile
16
+ * (operator directive 2026-08-24: "the yolo-bridge tile is a first class
17
+ * citizen and should have full access to a workspace" — supersedes the
18
+ * narrower peer-tile-parity design this file started with). No `tileIds`
19
+ * restriction, same as `mcp-token-broker.js`'s pod-side callers, which
20
+ * mint an agent's `mcp.defaultScopes` unrestricted by default.
21
+ *
22
+ * The scope LIST isn't hardcoded here (that would drift from
23
+ * `agents.json`, and this proxy has no access to that file — it runs on the
24
+ * operator's own machine, not inside a pod). Instead it's fetched live from
25
+ * the public, unauthenticated `GET /v1/mcp/scopes` — the full universe of
26
+ * valid scope names — and the mint route's own `partitionAgentScopes`
27
+ * silently drops whatever `agentId` (e.g. `claude`) isn't actually allowed
28
+ * (Phase 8a F8.14; drop-not-reject, so requesting too much never errors, it
29
+ * just narrows to the truth). This can only ever yield the SAME set the
30
+ * bound agent's own registry entry would authorize for a pod, and stays
31
+ * correct automatically as that registry changes — no client-side list to
32
+ * keep in sync.
33
+ *
34
+ * Several scopes in that universe (`send_to_tile`, `read_tile_output`,
35
+ * `remove_tile`, etc.) are `restricted`-exposure (`common-api/src/types/mcp.ts`),
36
+ * which `McpAuthService.mintDelegatedToken` clamps to a 60s TTL regardless
37
+ * of what `ttlSeconds` is requested (`RESTRICTED_SCOPE_TTL_SECONDS`) — same
38
+ * as a pod agent's own full-scope mint, and handled the same way: refresh
39
+ * well before that (see `REFRESH_BUFFER_MS`), plus a force-refresh-and-retry
40
+ * backstop on a real HTTP 401 (mirroring `mcp-proxy.js:456-460`'s shape) AND
41
+ * on the in-band UNAUTHORIZED shape this upstream actually uses in practice
42
+ * (an expired token is reported as a normal 200 JSON-RPC tool result, not a
43
+ * 401 — see `isUnauthorizedToolResult`'s doc comment for how this was found
44
+ * and confirmed, not assumed).
45
+ *
46
+ * Requires a per-attach secret on every request (Codex review, 2026-08-24,
47
+ * round 10): binding to `127.0.0.1` only keeps this off the local NETWORK,
48
+ * but it does nothing against another process on the SAME host — a
49
+ * different OS user, or a sandboxed process sharing the host's network
50
+ * namespace, can still reach a loopback port and would otherwise get a
51
+ * full-workspace-scoped delegated token minted on its behalf with zero
52
+ * credential of its own. `startMcpProxy` generates a random secret and
53
+ * hands it back in `McpProxyHandle.secret`.
54
+ *
55
+ * The secret itself is NEVER written into `.mcp.json` (round 12 — moved off
56
+ * round 10/11's original design, which wrote it directly into the entry's
57
+ * `headers`): many repos, including this one's own root, already track a
58
+ * `.mcp.json`, and a spawned coding agent running with YOLO-mode autonomy
59
+ * could commit/push it, publishing a live full-workspace credential.
60
+ * Instead `local-mcp-config.ts` writes a `${SECRET_ENV_VAR}` TEMPLATE string
61
+ * as the header value — safe to commit, since it resolves to nothing
62
+ * without the right environment — and `cli.ts` sets the real secret on
63
+ * `SECRET_ENV_VAR` in its OWN `process.env` right before spawning the local
64
+ * agent, which inherits it. Claude Code expands `${VAR}` in `.mcp.json`
65
+ * string fields against its own process env at load time, so the actual
66
+ * value only ever exists in memory: this server's, the daemon's, and the
67
+ * locally-spawned agent's.
68
+ */
69
+ import * as http from 'node:http';
70
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
71
+ const DEFAULT_MCP_URL = 'https://services.yolo.studio';
72
+ export function mcpUrl() {
73
+ return process.env.YOLOBRIDGE_MCP_URL || DEFAULT_MCP_URL;
74
+ }
75
+ // Requested as the max ordinary (non-restricted-exposure) TTL the mint route
76
+ // accepts; the server silently clamps to 60s anyway for this scope set
77
+ // (RESTRICTED_SCOPE_TTL_SECONDS) — requesting less than that would be a
78
+ // no-op, requesting more is harmless since the clamp always wins.
79
+ const REQUESTED_TTL_SECONDS = 300;
80
+ // Refresh well before the ACTUAL (clamped) 60s expiry, not before a naively
81
+ // assumed 300s one — a buffer sized for the default TTL would refresh AFTER
82
+ // this token already expired.
83
+ const REFRESH_BUFFER_MS = 15_000;
84
+ // Bounds the STARTUP mint only (Codex review, 2026-08-24): without this, a
85
+ // stalled scope-discovery or mint fetch would block `startMcpProxy` — and
86
+ // therefore `onAttached` — indefinitely, wedging `startLocalAgent` behind a
87
+ // best-effort enhancement that was supposed to degrade, not hang. Generous
88
+ // for a real round trip (2 sequential requests: scopes, then mint) but
89
+ // bounded; a caller that hits this still gets a working attach without MCP.
90
+ const STARTUP_MINT_TIMEOUT_MS = 15_000;
91
+ /** Header the local agent must echo back with the value from `.mcp.json`'s
92
+ * `headers` for this server entry (Codex review, 2026-08-24, round 10).
93
+ * Exported so `local-mcp-config.ts` writes the exact same key it checks. */
94
+ export const SECRET_HEADER = 'x-yolobridge-proxy-secret';
95
+ /**
96
+ * Env var the per-attach secret is exported under before the local agent is
97
+ * spawned — `.mcp.json`'s `headers` value (Codex review, 2026-08-24,
98
+ * round 12) is `${YOLOBRIDGE_MCP_PROXY_SECRET}` (a literal template string,
99
+ * expanded by Claude Code's OWN `${VAR}` support for `.mcp.json` at load
100
+ * time using ITS process env, inherited from this daemon), never the raw
101
+ * secret. Round 10/11 wrote the actual random value straight into the
102
+ * entry — safe against another local OS user reading the file (round 11's
103
+ * chmod fix), but not against Git: many repos (including this one's own
104
+ * root) already track a `.mcp.json`, and a spawned coding agent running
105
+ * with YOLO-mode autonomy can commit/push a live full-workspace credential
106
+ * without anyone reviewing the diff. The secret itself now never touches
107
+ * any file this daemon writes into the project tree.
108
+ */
109
+ export const SECRET_ENV_VAR = 'YOLOBRIDGE_MCP_PROXY_SECRET';
110
+ class TokenUnavailableError extends Error {
111
+ }
112
+ /**
113
+ * Tracks every outgoing fetch this proxy makes (mint, scope discovery, AND
114
+ * every forwarded `tools/call`) in one `Set<AbortController>`, so `stop()`
115
+ * (Codex review, 2026-08-24) can abort every in-flight request instead of
116
+ * `server.close()` silently waiting out a stalled upstream — `cmdAttach`
117
+ * awaits `stop()` before finishing cleanup, so an un-abortable hung fetch
118
+ * there would hang the whole detach. `abortAll()` is also how the startup
119
+ * mint's own timeout (`STARTUP_MINT_TIMEOUT_MS`) is enforced — same
120
+ * mechanism, just triggered by a timer instead of shutdown.
121
+ *
122
+ * `run()` keeps the controller registered for the caller's ENTIRE callback,
123
+ * not just until `fetch()` itself resolves (Codex review, 2026-08-24,
124
+ * round 14): `fetch()` resolves as soon as response HEADERS arrive, well
125
+ * before the body is read. The original design removed the controller in a
126
+ * `finally` right after that — if the upstream then stalled mid-BODY (e.g.
127
+ * `res.json()`/`res.text()` never resolves), neither the startup mint
128
+ * timeout nor `stop()` had a live controller left to abort, since it was
129
+ * already untracked. Every caller must do its ENTIRE fetch-and-consume
130
+ * inside the callback so the controller stays registered until the body is
131
+ * fully read (or the whole thing is aborted).
132
+ */
133
+ class RequestTracker {
134
+ controllers = new Set();
135
+ async run(body) {
136
+ const controller = new AbortController();
137
+ this.controllers.add(controller);
138
+ try {
139
+ return await body(controller.signal);
140
+ }
141
+ finally {
142
+ this.controllers.delete(controller);
143
+ }
144
+ }
145
+ abortAll() {
146
+ for (const c of this.controllers)
147
+ c.abort();
148
+ }
149
+ }
150
+ /** Fetches the full universe of valid scope names from the public,
151
+ * unauthenticated `GET /v1/mcp/scopes` — see this file's header comment
152
+ * for why the mint request asks for all of them rather than a hardcoded
153
+ * subset (the mint route itself narrows to what `agentId` is actually
154
+ * allowed). */
155
+ async function fetchAllScopes(apiUrl, fetchImpl, tracker) {
156
+ return tracker.run(async (signal) => {
157
+ const res = await fetchImpl(`${apiUrl.replace(/\/+$/, '')}/v1/mcp/scopes`, { signal });
158
+ if (!res.ok)
159
+ throw new TokenUnavailableError(`scope discovery failed: HTTP ${res.status}`);
160
+ const body = (await res.json());
161
+ if (!Array.isArray(body?.scopes) || body.scopes.length === 0) {
162
+ throw new TokenUnavailableError('scope discovery returned an unexpected shape');
163
+ }
164
+ return body.scopes;
165
+ });
166
+ }
167
+ /** One cached, self-refreshing, workspace-wide token — scoped to whatever
168
+ * `agentId`'s registry entry actually allows out of the full scope
169
+ * universe (see this file's header comment). */
170
+ function makeTokenCache(apiUrl, getAccessToken, workspaceId, agentId, fetchImpl, tracker) {
171
+ let cached;
172
+ async function mint() {
173
+ const scopes = await fetchAllScopes(apiUrl, fetchImpl, tracker);
174
+ return tracker.run(async (signal) => {
175
+ const res = await fetchImpl(`${apiUrl.replace(/\/+$/, '')}/v1/mcp/tokens`, {
176
+ method: 'POST',
177
+ headers: { Authorization: `Bearer ${getAccessToken()}`, 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({
179
+ workspaceId,
180
+ agentId,
181
+ scopes,
182
+ ttlSeconds: REQUESTED_TTL_SECONDS,
183
+ }),
184
+ signal,
185
+ });
186
+ if (!res.ok) {
187
+ let message = `HTTP ${res.status}`;
188
+ try {
189
+ const body = (await res.json());
190
+ if (typeof body?.error === 'string')
191
+ message = body.error;
192
+ }
193
+ catch {
194
+ /* keep the HTTP-status fallback */
195
+ }
196
+ throw new TokenUnavailableError(`mint failed: ${message}`);
197
+ }
198
+ const body = (await res.json());
199
+ if (typeof body?.token !== 'string' || typeof body?.expiresAt !== 'string') {
200
+ throw new TokenUnavailableError('mint returned an unexpected shape');
201
+ }
202
+ return { token: body.token, expiresAtMs: new Date(body.expiresAt).getTime() };
203
+ });
204
+ }
205
+ // Coalesces concurrent mints into ONE in-flight request (Codex review,
206
+ // 2026-08-24, round 6): agents commonly issue several tool calls at
207
+ // once, and without this every one of them that happened to observe an
208
+ // expired/near-expiry cache independently kicked off its own
209
+ // scope-discovery + mint round trip — a recurring burst against the
210
+ // mint endpoint that could trigger throttling, not just wasted work.
211
+ // `forceRefresh` shares the same gate: if a 401-triggered refresh
212
+ // overlaps a plain getToken() mint already in flight, they both just
213
+ // want "a fresh token," so reusing that one request is correct, not a
214
+ // shortcut.
215
+ let pendingMint;
216
+ function mintOnce() {
217
+ if (!pendingMint) {
218
+ pendingMint = mint().finally(() => { pendingMint = undefined; });
219
+ }
220
+ return pendingMint;
221
+ }
222
+ return {
223
+ async getToken() {
224
+ if (cached && Date.now() < cached.expiresAtMs - REFRESH_BUFFER_MS)
225
+ return cached.token;
226
+ cached = await mintOnce();
227
+ return cached.token;
228
+ },
229
+ async forceRefresh() {
230
+ cached = await mintOnce();
231
+ return cached.token;
232
+ },
233
+ };
234
+ }
235
+ /**
236
+ * Starts the local proxy. Returns `undefined` (never throws) if the initial
237
+ * mint fails — MCP access is a best-effort enhancement on top of a tile
238
+ * that already works without it (send_to_tile/read_tile_output into the
239
+ * daemon's own PTY are unaffected either way), so a mint failure (e.g. an
240
+ * unregistered --agent binary) must not abort the whole `attach`.
241
+ */
242
+ export async function startMcpProxy(opts) {
243
+ const log = opts.log ?? (() => { });
244
+ const fetchImpl = opts.fetchImpl ?? fetch;
245
+ const tracker = new RequestTracker();
246
+ const tokenCache = makeTokenCache(opts.apiUrl, opts.getAccessToken, opts.workspaceId, opts.agentId, fetchImpl, tracker);
247
+ // Bounded (STARTUP_MINT_TIMEOUT_MS): a stalled scope-discovery/mint fetch
248
+ // must not block `onAttached` from ever reaching `startLocalAgent`
249
+ // (Codex review, 2026-08-24). `abortAll()` only affects requests in
250
+ // flight AT the timeout — this timer is cleared as soon as the mint
251
+ // settles either way, so it can never fire against the running proxy's
252
+ // later request traffic.
253
+ const startupTimeout = setTimeout(() => tracker.abortAll(), opts.mintTimeoutMs ?? STARTUP_MINT_TIMEOUT_MS);
254
+ try {
255
+ await tokenCache.getToken();
256
+ }
257
+ catch (err) {
258
+ log(`yolo-bridge: local MCP access unavailable (${err instanceof Error ? err.message : String(err)}) — continuing without it.`);
259
+ return undefined;
260
+ }
261
+ finally {
262
+ clearTimeout(startupTimeout);
263
+ }
264
+ const upstream = mcpUrl().replace(/\/+$/, '');
265
+ const secret = randomBytes(32).toString('hex');
266
+ const server = http.createServer((req, res) => {
267
+ if (!hasValidSecret(req, secret)) {
268
+ res.writeHead(401, { 'Content-Type': 'application/json' });
269
+ res.end(JSON.stringify({ error: 'missing or invalid proxy credential' }));
270
+ return;
271
+ }
272
+ handleRequest(req, res, upstream, tokenCache.getToken, tokenCache.forceRefresh, fetchImpl, tracker, log).catch((err) => {
273
+ log(`yolo-bridge: local MCP proxy error: ${err instanceof Error ? err.message : String(err)}`);
274
+ if (!res.headersSent) {
275
+ res.writeHead(502, { 'Content-Type': 'application/json' });
276
+ }
277
+ res.end(JSON.stringify({ error: 'local MCP proxy error' }));
278
+ });
279
+ });
280
+ await new Promise((resolve, reject) => {
281
+ server.once('error', reject);
282
+ // 127.0.0.1 explicitly — never '0.0.0.0'/omitted (which node-pty's host
283
+ // stack treats as "all interfaces"). This server holds a real delegated
284
+ // token in memory; binding wider would expose it to the local network.
285
+ server.listen(0, '127.0.0.1', () => resolve());
286
+ });
287
+ const { port } = server.address();
288
+ const url = `http://127.0.0.1:${port}/mcp`;
289
+ log(`yolo-bridge: local MCP access ready (${url}).`);
290
+ return {
291
+ url,
292
+ secret,
293
+ stop: () => new Promise((resolve) => {
294
+ // Two separate reasons server.close() alone could hang (Codex
295
+ // review, 2026-08-24; `cmdAttach` awaits `stop()` before finishing
296
+ // its own cleanup, so either one hangs the whole detach):
297
+ // 1. A stalled/slow fetch to `upstream` — abortAll() rejects it,
298
+ // which lets handleRequest's own catch send a response and end
299
+ // the local connection.
300
+ // 2. Node's http server keeps a client's underlying socket open
301
+ // for keep-alive by default; close() only waits for connections
302
+ // to end NATURALLY (it does not itself close idle ones), so a
303
+ // client that doesn't proactively close its socket (real MCP
304
+ // clients keep HTTP connections alive) would otherwise still
305
+ // hang close() even after (1) ends the in-flight request/response.
306
+ // closeAllConnections() (Node >=18.2, this package requires
307
+ // >=20) force-closes every connection immediately.
308
+ tracker.abortAll();
309
+ server.close(() => resolve());
310
+ server.closeAllConnections();
311
+ }),
312
+ };
313
+ }
314
+ /** Injects `_delegatedToken` into a `tools/call` request's `params.arguments`
315
+ * — the exact shape `mcp-proxy.js`'s own `injectToken()` uses, not
316
+ * reinvented. Returns the body unchanged if it isn't a tools/call (or isn't
317
+ * valid JSON — the upstream can reject that on its own terms). */
318
+ /** Injects `_delegatedToken` into one `tools/call` message, creating
319
+ * `params.arguments` first if it's absent (Codex review, 2026-08-24,
320
+ * round 4) -- MCP allows a zero-input tool's call to omit `arguments`
321
+ * entirely, and the original `msg.params?.arguments` truthiness check
322
+ * skipped injection for exactly that shape, so any zero-input tool always
323
+ * reached the upstream with no token and came back UNAUTHORIZED. Mutates
324
+ * and returns true if this message needed the token, so callers can tell
325
+ * whether anything actually changed. */
326
+ function injectTokenIntoMessage(msg, token) {
327
+ if (msg?.method !== 'tools/call' || typeof msg.params !== 'object' || msg.params === null)
328
+ return false;
329
+ if (typeof msg.params.arguments !== 'object' || msg.params.arguments === null)
330
+ msg.params.arguments = {};
331
+ msg.params.arguments._delegatedToken = token;
332
+ return true;
333
+ }
334
+ function injectToken(rawBody, token) {
335
+ try {
336
+ const parsed = JSON.parse(rawBody);
337
+ if (Array.isArray(parsed)) {
338
+ let changed = false;
339
+ for (const msg of parsed) {
340
+ if (injectTokenIntoMessage(msg, token))
341
+ changed = true;
342
+ }
343
+ return changed ? JSON.stringify(parsed) : rawBody;
344
+ }
345
+ return injectTokenIntoMessage(parsed, token) ? JSON.stringify(parsed) : rawBody;
346
+ }
347
+ catch {
348
+ return rawBody;
349
+ }
350
+ }
351
+ async function readBody(req) {
352
+ const chunks = [];
353
+ for await (const chunk of req)
354
+ chunks.push(chunk);
355
+ return Buffer.concat(chunks).toString('utf-8');
356
+ }
357
+ /**
358
+ * Detects an UNAUTHORIZED tool error IN-BAND, not via HTTP status.
359
+ *
360
+ * Real bug found live (2026-08-24): the MCP SDK reports a thrown tool error
361
+ * (`McpToolError('Invalid delegated token', 'UNAUTHORIZED')`,
362
+ * `yolo-studio-mcp/src/auth/token-validator.ts:55`) as a normal JSON-RPC
363
+ * SUCCESS response -- HTTP 200, `result.content[0].text` holding the error
364
+ * as a JSON string -- standard MCP behavior (tool execution errors are
365
+ * in-band content, not a transport-level failure). Confirmed empirically
366
+ * against the real upstream with a deliberately invalid token before fixing
367
+ * this, not assumed: `curl` returned `HTTP 200` with
368
+ * `{"result":{"content":[{"type":"text","text":"{\"error\":\"Invalid
369
+ * delegated token\",\"code\":\"UNAUTHORIZED\"}"}]}, ...}`. The original
370
+ * `result.status === 401` check below can therefore never fire for the
371
+ * exact case it exists to catch -- a token that expired between mint and
372
+ * use (the 60s `RESTRICTED_SCOPE_TTL_SECONDS` clamp this file's header
373
+ * comment describes makes this a real, not theoretical, window). Parses
374
+ * defensively (a malformed/unexpected shape is treated as "not this error",
375
+ * never thrown) -- this must not become a new way to crash the proxy.
376
+ *
377
+ * Also checks a JSON-RPC BATCH response, not just a single object (Codex
378
+ * review, 2026-08-24, round 16): `injectToken` above already handles a
379
+ * batched REQUEST (`Array.isArray(parsed)`), so a batched RESPONSE is
380
+ * equally real on the way back. Without this, a batch containing an
381
+ * UNAUTHORIZED result was invisible to this check (`parsed?.result` is
382
+ * `undefined` on an array), so the force-refresh-and-retry never fired --
383
+ * every batched call kept failing with the same stale token until the
384
+ * cache's own `REFRESH_BUFFER_MS`-driven expiry eventually caught up on
385
+ * its own, not on the first sign of trouble.
386
+ */
387
+ function isUnauthorizedResponseItem(response) {
388
+ const content = response?.result?.content;
389
+ if (!Array.isArray(content))
390
+ return false;
391
+ for (const item of content) {
392
+ if (typeof item?.text !== 'string')
393
+ continue;
394
+ try {
395
+ const inner = JSON.parse(item.text);
396
+ if (inner?.code === 'UNAUTHORIZED')
397
+ return true;
398
+ }
399
+ catch {
400
+ // item.text wasn't JSON -- not this error shape, keep checking other
401
+ // content items rather than guessing from a substring match (a
402
+ // legitimate tool result could coincidentally contain the word).
403
+ }
404
+ }
405
+ return false;
406
+ }
407
+ function isUnauthorizedToolResult(text) {
408
+ try {
409
+ const parsed = JSON.parse(text);
410
+ const responses = Array.isArray(parsed) ? parsed : [parsed];
411
+ return responses.some(isUnauthorizedResponseItem);
412
+ }
413
+ catch {
414
+ // Top-level body wasn't JSON at all -- not this error shape.
415
+ return false;
416
+ }
417
+ }
418
+ /**
419
+ * Picks out only the batch elements that need a retry, instead of replaying
420
+ * the WHOLE original batch (Codex review, 2026-08-24, round 20): a JSON-RPC
421
+ * batch with a short-lived token can come back 200-with-mixed-results --
422
+ * some calls already succeeded, one or more failed in-band UNAUTHORIZED
423
+ * because the token expired partway through. The previous retry resent the
424
+ * entire original body, including the already-successful calls; many MCP
425
+ * tools are not idempotent, so a mutating call earlier in the batch would
426
+ * execute a SECOND time. Matches request/response pairs by JSON-RPC `id`
427
+ * (batch elements needing a response always carry one; bare notifications
428
+ * never appear in the response array either, so they're naturally excluded
429
+ * from both sides).
430
+ *
431
+ * Returns `null` when there's nothing to partition -- a non-batch request,
432
+ * an unparseable/non-array response, or a batch where nothing came back
433
+ * UNAUTHORIZED -- so the caller falls back to its existing whole-body retry,
434
+ * which is already correct and minimal for a single (non-batch) request.
435
+ */
436
+ function buildUnauthorizedRetryBatch(requestBody, responseText) {
437
+ let requestParsed;
438
+ let responseParsed;
439
+ try {
440
+ requestParsed = JSON.parse(requestBody);
441
+ responseParsed = JSON.parse(responseText);
442
+ }
443
+ catch {
444
+ return null;
445
+ }
446
+ if (!Array.isArray(requestParsed) || !Array.isArray(responseParsed))
447
+ return null;
448
+ const unauthorizedIds = new Set();
449
+ for (const response of responseParsed) {
450
+ if (response?.id === undefined || response?.id === null)
451
+ continue;
452
+ if (isUnauthorizedResponseItem(response))
453
+ unauthorizedIds.add(response.id);
454
+ }
455
+ if (unauthorizedIds.size === 0)
456
+ return null;
457
+ const requestSubset = requestParsed.filter((msg) => msg?.id !== undefined && msg?.id !== null && unauthorizedIds.has(msg.id));
458
+ if (requestSubset.length === 0)
459
+ return null;
460
+ return { requestSubset: JSON.stringify(requestSubset), ids: unauthorizedIds };
461
+ }
462
+ /**
463
+ * Splices a retried subset's responses back into their original positions
464
+ * in the first attempt's batch response, leaving every already-successful
465
+ * element exactly as it came back the first time. Falls back to the ORIGINAL
466
+ * response text on any parse failure -- this only ever runs after a retry
467
+ * that itself only fires for a confirmed-parseable batch (`buildUnauthorized
468
+ * RetryBatch` already validated both shapes), so a failure here means
469
+ * something unexpected changed between the two parses; discarding the
470
+ * (already-good) first response in that case would be strictly worse than
471
+ * keeping it.
472
+ */
473
+ function mergeRetryResponses(originalResponseText, retryResponseText, retriedIds) {
474
+ try {
475
+ const original = JSON.parse(originalResponseText);
476
+ if (!Array.isArray(original))
477
+ return originalResponseText;
478
+ const retryParsed = JSON.parse(retryResponseText);
479
+ const retryArray = Array.isArray(retryParsed) ? retryParsed : [retryParsed];
480
+ const retryById = new Map();
481
+ for (const r of retryArray) {
482
+ if (r?.id !== undefined && r?.id !== null)
483
+ retryById.set(r.id, r);
484
+ }
485
+ const merged = original.map((r) => (r?.id !== undefined && retriedIds.has(r.id) && retryById.has(r.id) ? retryById.get(r.id) : r));
486
+ return JSON.stringify(merged);
487
+ }
488
+ catch {
489
+ return originalResponseText;
490
+ }
491
+ }
492
+ async function forwardOnce(upstream, method, headers, body, fetchImpl, tracker) {
493
+ return tracker.run(async (signal) => {
494
+ const res = await fetchImpl(`${upstream}/mcp`, { method, headers, body, signal });
495
+ const text = await res.text();
496
+ return { status: res.status, headers: res.headers, text };
497
+ });
498
+ }
499
+ /** Constant-time comparison against the request's `SECRET_HEADER` value —
500
+ * missing, wrong-length, or mismatched all fail closed. `timingSafeEqual`
501
+ * throws on a length mismatch rather than returning false, so length is
502
+ * checked first. */
503
+ function hasValidSecret(req, secret) {
504
+ const provided = req.headers[SECRET_HEADER];
505
+ if (typeof provided !== 'string')
506
+ return false;
507
+ const providedBuf = Buffer.from(provided, 'utf-8');
508
+ const secretBuf = Buffer.from(secret, 'utf-8');
509
+ if (providedBuf.length !== secretBuf.length)
510
+ return false;
511
+ return timingSafeEqual(providedBuf, secretBuf);
512
+ }
513
+ async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchImpl, tracker, log) {
514
+ const method = req.method ?? 'POST';
515
+ const body = method === 'POST' || method === 'DELETE' ? await readBody(req) : undefined;
516
+ const headers = { Accept: 'application/json, text/event-stream' };
517
+ const incomingContentType = req.headers['content-type'];
518
+ if (typeof incomingContentType === 'string')
519
+ headers['Content-Type'] = incomingContentType;
520
+ else if (body)
521
+ headers['Content-Type'] = 'application/json';
522
+ let forwardedBody = body;
523
+ if (method === 'POST' && body) {
524
+ try {
525
+ const token = await getToken();
526
+ forwardedBody = injectToken(body, token);
527
+ }
528
+ catch (err) {
529
+ log(`yolo-bridge: MCP token unavailable: ${err instanceof Error ? err.message : String(err)}`);
530
+ res.writeHead(503, { 'Content-Type': 'application/json' });
531
+ res.end(JSON.stringify({ error: 'MCP token unavailable', code: 'MCP_TOKEN_UNAVAILABLE', retryable: true }));
532
+ return;
533
+ }
534
+ }
535
+ let result = await forwardOnce(upstream, method, headers, forwardedBody, fetchImpl, tracker);
536
+ // Retry once on 401, force-refreshing first — mirrors mcp-proxy.js's own
537
+ // retry shape exactly (containers/services/container-api/mcp-proxy.js:456-460).
538
+ // ALSO retry on a 200-with-in-band-UNAUTHORIZED (see
539
+ // isUnauthorizedToolResult's doc comment) — a real 401 from this upstream
540
+ // has never actually been observed; the in-band case is the one that
541
+ // matters in practice.
542
+ if (method === 'POST' && body && (result.status === 401 || isUnauthorizedToolResult(result.text))) {
543
+ log(`yolo-bridge: MCP upstream reported an invalid/expired token (status ${result.status}), force-refreshing`);
544
+ try {
545
+ const refreshed = await forceRefresh();
546
+ // A transport-level 401 means nothing in the request was ever applied
547
+ // (the upstream rejected it before running any tool), so retrying the
548
+ // whole original body is correct and minimal there. Only an in-band
549
+ // 200-with-mixed-results batch (Codex review, 2026-08-24, round 20)
550
+ // needs the narrower partial-batch retry below -- see
551
+ // `buildUnauthorizedRetryBatch`'s doc comment.
552
+ const retryBatch = result.status === 401 ? null : buildUnauthorizedRetryBatch(body, result.text);
553
+ if (retryBatch) {
554
+ const reinjected = injectToken(retryBatch.requestSubset, refreshed);
555
+ const retryResult = await forwardOnce(upstream, method, headers, reinjected, fetchImpl, tracker);
556
+ const mergedText = mergeRetryResponses(result.text, retryResult.text, retryBatch.ids);
557
+ // Only adopt the SUBSET retry's own status/headers when it actually
558
+ // succeeded at the transport level (Codex review, 2026-08-24, round
559
+ // 26): the trigger for this whole branch guarantees the ORIGINAL
560
+ // response was a 200 (a transport-level 401 takes the whole-body
561
+ // retry path above instead), so a retry that itself comes back
562
+ // non-2xx (a genuine upstream 500, not a network error -- that
563
+ // throws and is caught below, leaving `result` untouched) must not
564
+ // promote its OWN failure status onto the merged response. The body
565
+ // already correctly falls back to the ORIGINAL (still-successful-
566
+ // for-the-other-elements) text in that case; overwriting the status
567
+ // too would tell the caller the WHOLE batch failed and invite a
568
+ // blind full retry that double-mutates the already-successful
569
+ // elements — exactly what this partial-retry logic exists to
570
+ // prevent, just via the status code instead of the body this time.
571
+ result = retryResult.status >= 200 && retryResult.status < 300
572
+ ? { ...retryResult, text: mergedText }
573
+ : { ...result, text: mergedText };
574
+ }
575
+ else {
576
+ const reinjected = injectToken(body, refreshed);
577
+ result = await forwardOnce(upstream, method, headers, reinjected, fetchImpl, tracker);
578
+ }
579
+ }
580
+ catch (err) {
581
+ log(`yolo-bridge: MCP token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
582
+ }
583
+ }
584
+ const outHeaders = {};
585
+ const contentType = result.headers.get('content-type');
586
+ if (contentType)
587
+ outHeaders['Content-Type'] = contentType;
588
+ res.writeHead(result.status, outHeaders);
589
+ res.end(result.text);
590
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
5
5
  "license": "MIT",
6
6
  "type": "module",