@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.
- package/dist/atomic-write.js +297 -0
- package/dist/attach-cmd.js +74 -3
- package/dist/cli.js +285 -44
- package/dist/device-auth.js +10 -9
- package/dist/git-safety.js +151 -0
- package/dist/local-mcp-config.js +877 -0
- package/dist/local-mcp-trust.js +371 -0
- package/dist/login-cmd.js +12 -4
- package/dist/mcp-proxy.js +590 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|