@yolo-labs/yolobridge 0.19.0 → 0.21.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.
@@ -303,3 +303,24 @@ export async function finalizeShare(cfg, workspaceId, attachmentId, assetId) {
303
303
  const body = (await res.json());
304
304
  return { assetId: body?.asset?.assetId ?? assetId };
305
305
  }
306
+ /**
307
+ * Phase 3, optional: write an already-shared asset into ANOTHER tile's session
308
+ * pod, so the agent there can open it as a real file.
309
+ *
310
+ * Only DELIVERY crosses to another tile. The asset itself stays on this
311
+ * attachment's tile — the server enforces that, and nothing here can ask
312
+ * otherwise.
313
+ */
314
+ export async function deliverShare(cfg, workspaceId, attachmentId, assetId, targetTileId) {
315
+ const fetchImpl = cfg.fetchImpl ?? fetch;
316
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/deliver`, {
317
+ method: 'POST',
318
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({ assetId, targetTileId }),
320
+ });
321
+ if (!res.ok) {
322
+ const { message, code } = await parseErrorBody(res);
323
+ throw new YoloBridgeApiError(message, res.status, code);
324
+ }
325
+ return (await res.json());
326
+ }
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { realpathSync, existsSync, readFileSync } from 'node:fs';
22
22
  import { hostname } from 'node:os';
23
23
  import { runLogin } from './login-cmd.js';
24
24
  import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
25
- import { runShare } from './share-cmd.js';
25
+ import { runShare, runDeliver } from './share-cmd.js';
26
26
  import { runAllow } from './approved-paths.js';
27
27
  import { runDetach } from './detach-cmd.js';
28
28
  import { getStatus, formatStatus } from './status-cmd.js';
@@ -106,6 +106,10 @@ function printHelp() {
106
106
  ' The daemon\'s own working directory is always allowed.',
107
107
  ' share <path> Share a local file with the attached workspace, so a cloud',
108
108
  ' agent can see it. Push only — nothing reads your disk remotely.',
109
+ ' [--to <tileId>] Also write it into that tile\'s session, so its agent can open it.',
110
+ ' deliver <assetId> Write an ALREADY-shared file into a tile\'s session, without',
111
+ ' --to <tileId> uploading it again. This is the retry path when a share',
112
+ ' uploaded fine but the delivery failed.',
109
113
  ' status Print local login/attach state.',
110
114
  ' version Print the installed yolo-bridge version (also --version, -v).',
111
115
  ' --help Print this help.',
@@ -349,6 +353,18 @@ async function cmdAttach(args) {
349
353
  // guess which studio_list_tiles row is itself — and a backwards
350
354
  // guess sends the prompt into its OWN input.
351
355
  callerTileId: tileId,
356
+ // Tools the proxy serves ITSELF (local-mcp-tools.ts). A cloud tool
357
+ // cannot read this machine's disk, so sharing a local file is the
358
+ // one thing that has to be answered here.
359
+ //
360
+ // `implicitRoots` is the directory the daemon was launched in — the
361
+ // project the agent is already working in and can read anyway.
362
+ // Anything outside needs `yolo-bridge allow`.
363
+ localTools: {
364
+ workspaceId,
365
+ implicitRoots: [process.cwd()],
366
+ commonApiBaseUrl: apiUrl(),
367
+ },
352
368
  log: (line) => process.stdout.write(`${line}\n`),
353
369
  });
354
370
  // Command-line MCP configuration, never a file in the project
@@ -554,6 +570,22 @@ function cmdStatus() {
554
570
  process.stdout.write(`${formatStatus(getStatus())}\n`);
555
571
  return 0;
556
572
  }
573
+ async function cmdDeliver(args) {
574
+ const assetId = args.find((a) => !a.startsWith('-') && a !== args[args.indexOf('--to') + 1]);
575
+ const toIdx = args.indexOf('--to');
576
+ const targetTileId = toIdx >= 0 ? args[toIdx + 1] : undefined;
577
+ if (!assetId || !targetTileId) {
578
+ process.stderr.write('yolo-bridge deliver: usage — yolo-bridge deliver <assetId> --to <tileId>\n');
579
+ return 64;
580
+ }
581
+ const result = await runDeliver(assetId, targetTileId, { commonApiBaseUrl: apiUrl() });
582
+ if (!result.ok) {
583
+ process.stderr.write(`yolo-bridge deliver: ${result.message}\n`);
584
+ return 1;
585
+ }
586
+ process.stdout.write(`${result.path}\n`);
587
+ return 0;
588
+ }
557
589
  function cmdAllow(args) {
558
590
  const result = runAllow(args);
559
591
  if (!result.ok) {
@@ -569,13 +601,22 @@ async function cmdShare(args) {
569
601
  process.stderr.write('yolo-bridge share: a file path is required.\n\n yolo-bridge share ./cut.mp4\n');
570
602
  return 64;
571
603
  }
572
- const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl() });
604
+ // `--to <tileId>` also writes the file into that tile's session pod.
605
+ const toIdx = args.indexOf('--to');
606
+ const targetTileId = toIdx >= 0 ? args[toIdx + 1] : undefined;
607
+ if (toIdx >= 0 && (!targetTileId || targetTileId.startsWith('-'))) {
608
+ process.stderr.write('yolo-bridge share: `--to` needs a tile id.\n');
609
+ return 64;
610
+ }
611
+ const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl(), targetTileId });
573
612
  if (!result.ok) {
574
613
  // Every one of these is an operator-actionable condition, not a bug, so it
575
614
  // prints as a sentence with no stack trace.
576
615
  process.stderr.write(`yolo-bridge share: ${result.message}\n`);
577
616
  return 1;
578
617
  }
618
+ if (result.deliveredPath)
619
+ process.stdout.write(`${result.deliveredPath}\n`);
579
620
  return 0;
580
621
  }
581
622
  async function cmdWorkspaces() {
@@ -634,6 +675,8 @@ async function main() {
634
675
  return cmdDetach();
635
676
  case 'allow':
636
677
  return cmdAllow(rest);
678
+ case 'deliver':
679
+ return cmdDeliver(rest);
637
680
  case 'share':
638
681
  return cmdShare(rest);
639
682
  case 'status':
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Tools the daemon serves ITSELF, instead of forwarding to the cloud MCP.
3
+ *
4
+ * WHY ANY TOOL IS LOCAL. `mcp-proxy.ts` forwards the attached agent's MCP
5
+ * traffic to the cloud `yolo-studio-mcp`, and the agent already has full
6
+ * workspace-wide scope there. But a cloud tool executes in the cloud and cannot
7
+ * read the operator's disk. Sharing a local file is therefore the one thing the
8
+ * proxy has to answer on its own.
9
+ *
10
+ * TWO PROPERTIES THIS FILE EXISTS TO KEEP:
11
+ *
12
+ * 1. A local call is NEVER FORWARDED, so the operator's file never becomes a
13
+ * cloud request, and no delegated cloud token is minted for it. The
14
+ * interception in `mcp-proxy.ts` runs BEFORE `getToken()` for exactly this
15
+ * reason — see the call site.
16
+ * 2. The upload opens the RESOLVED path the approval check returned, never
17
+ * the string the agent passed. Checking one path and opening another is
18
+ * how symlink containment gets defeated: an in-tree link can be repointed
19
+ * between the two.
20
+ *
21
+ * ⚠️ THE APPROVAL LIST IS NOT A SECURITY BOUNDARY. The agent runs as the same
22
+ * OS user with shell access, so it can widen the list itself, and it could
23
+ * already `curl -T` a file out without us. What this buys is that ACCIDENTS are
24
+ * prevented, the credentialed path stays deliberate, and grants are auditable.
25
+ * Do not write a message here claiming more than that.
26
+ */
27
+ import { checkPathApproved } from './approved-paths.js';
28
+ import { runShare, runDeliver, describeShareFailure } from './share-cmd.js';
29
+ /** Namespaced so it can never collide with a forwarded cloud tool name. */
30
+ export const SHARE_FILE_TOOL = 'yolobridge_share_file';
31
+ export const LOCAL_TOOL_DEFINITIONS = [
32
+ {
33
+ name: SHARE_FILE_TOOL,
34
+ description: 'Send a file from the LOCAL machine this agent is running on up to the cloud workspace, '
35
+ + 'so a cloud agent can see it. Push only — this cannot read paths the operator has not '
36
+ + 'approved, and nothing in the cloud can pull files from this machine.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ path: {
41
+ type: 'string',
42
+ description: 'Path to the local file to send. Must be inside an approved directory.',
43
+ },
44
+ assetId: {
45
+ type: 'string',
46
+ description: 'Optional. Deliver an ALREADY-shared asset instead of uploading again — use this to '
47
+ + 'retry a delivery that failed after a successful upload. Requires targetTileId; '
48
+ + '`path` is ignored when this is given.',
49
+ },
50
+ targetTileId: {
51
+ type: 'string',
52
+ description: 'Optional. A tile in this workspace to ALSO write the file into, so the agent running '
53
+ + 'there can open it directly. Use studio_list_tiles to find the id. Omit to leave the '
54
+ + 'file in the workspace only.',
55
+ },
56
+ },
57
+ // TWO CALL SHAPES, so `path` cannot be unconditionally required:
58
+ // { path } — share a local file
59
+ // { assetId, targetTileId } — deliver one already shared, no re-upload
60
+ // A schema-aware client validates before dispatching, so requiring `path`
61
+ // outright made the retry form unreachable no matter what the handler
62
+ // accepted. (codex P1, gpt-5.6-sol — the unit tests call the handler
63
+ // directly and never saw it.)
64
+ anyOf: [
65
+ { required: ['path'] },
66
+ { required: ['assetId', 'targetTileId'] },
67
+ ],
68
+ additionalProperties: false,
69
+ },
70
+ },
71
+ ];
72
+ function toolResult(id, text, isError = false) {
73
+ return {
74
+ jsonrpc: '2.0',
75
+ id,
76
+ result: { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) },
77
+ };
78
+ }
79
+ /** Is this parsed JSON-RPC message a call to one of our local tools? */
80
+ export function isLocalToolCall(msg) {
81
+ const m = msg;
82
+ if (!m || m.method !== 'tools/call')
83
+ return false;
84
+ return typeof m.params?.name === 'string'
85
+ && LOCAL_TOOL_DEFINITIONS.some((t) => t.name === m.params.name);
86
+ }
87
+ /**
88
+ * Run one local tool call and produce its JSON-RPC response.
89
+ *
90
+ * Errors come back as a tool result with `isError`, not a JSON-RPC error: a
91
+ * refused path is a normal answer the agent should read and act on, not a
92
+ * protocol fault.
93
+ */
94
+ export async function runLocalToolCall(msg, ctx) {
95
+ // A message with NO `id` is a notification: JSON-RPC says do the work and
96
+ // send nothing back. `msg.id ?? null` would have turned that into a normal
97
+ // reply carrying `id: null` — a spurious entry in a batch, and a body where a
98
+ // notification-only request expects none. An EXPLICIT null id is a different
99
+ // thing and still gets answered. (codex P2, gpt-5.6-sol.)
100
+ const isNotification = !('id' in msg);
101
+ const id = msg.id ?? null;
102
+ const name = msg.params?.name;
103
+ const args = msg.params?.arguments ?? {};
104
+ if (name !== SHARE_FILE_TOOL) {
105
+ return isNotification ? undefined : toolResult(id, `Unknown local tool: ${String(name)}`, true);
106
+ }
107
+ const targetTileId = typeof args.targetTileId === 'string' && args.targetTileId ? args.targetTileId : undefined;
108
+ const retryAssetId = typeof args.assetId === 'string' && args.assetId ? args.assetId : undefined;
109
+ // Delivery-only retry: the bytes are already in the workspace, so there is no
110
+ // path to approve and nothing to read from disk.
111
+ if (retryAssetId) {
112
+ if (!targetTileId) {
113
+ return isNotification ? undefined : toolResult(id, '`targetTileId` is required when retrying delivery of an assetId.', true);
114
+ }
115
+ const redeliver = await (ctx.deliverImpl ?? runDeliver)(retryAssetId, targetTileId, {
116
+ commonApiBaseUrl: ctx.commonApiBaseUrl,
117
+ expectedWorkspaceId: ctx.workspaceId,
118
+ });
119
+ if (isNotification)
120
+ return undefined;
121
+ return redeliver.ok
122
+ ? toolResult(id, `Delivered asset ${retryAssetId} to tile ${targetTileId} at ${redeliver.path}.`)
123
+ : toolResult(id, redeliver.message, true);
124
+ }
125
+ const rawPath = typeof args.path === 'string' ? args.path : '';
126
+ if (!rawPath)
127
+ return isNotification ? undefined : toolResult(id, 'A `path` is required.', true);
128
+ const check = (ctx.checkImpl ?? checkPathApproved)(rawPath, ctx.workspaceId, ctx.implicitRoots);
129
+ if (!check.approved || !check.resolvedPath) {
130
+ return isNotification ? undefined : toolResult(id, check.reason ?? `${rawPath} is not an approved path.`, true);
131
+ }
132
+ try {
133
+ // Property 2: open what the check RESOLVED, not what the caller passed.
134
+ const result = await (ctx.shareImpl ?? runShare)(check.resolvedPath, {
135
+ commonApiBaseUrl: ctx.commonApiBaseUrl,
136
+ targetTileId,
137
+ // The approval was checked against THIS workspace. The attachment on disk
138
+ // can have been replaced by a second `attach` since the proxy started, so
139
+ // bind the upload to the same workspace or refuse. (codex P2.)
140
+ expectedWorkspaceId: ctx.workspaceId,
141
+ write: () => { },
142
+ });
143
+ if (isNotification)
144
+ return undefined;
145
+ if (!result.ok)
146
+ return toolResult(id, result.message, true);
147
+ // When it was delivered, the PATH is the useful half — it is what the
148
+ // target agent needs to be told in order to open the file.
149
+ return toolResult(id, result.deliveredPath
150
+ ? `Shared as asset ${result.assetId} and written into tile ${targetTileId} at ${result.deliveredPath}. `
151
+ + 'Tell that tile\'s agent to open that path.'
152
+ : `Shared as asset ${result.assetId}. It is now visible in the cloud workspace.`);
153
+ }
154
+ catch (err) {
155
+ return isNotification ? undefined : toolResult(id, describeShareFailure(err), true);
156
+ }
157
+ }
158
+ /**
159
+ * Split an incoming body into "answered here" and "still needs the cloud".
160
+ *
161
+ * Handles the batch form because `injectToken` already has to, and a batch that
162
+ * mixes a local tool with cloud tools must not lose either half.
163
+ *
164
+ * Returns `undefined` when nothing is local — the overwhelmingly common case,
165
+ * so the normal path pays one `JSON.parse` and nothing else.
166
+ */
167
+ export async function interceptLocalTools(rawBody, ctx) {
168
+ let parsed;
169
+ try {
170
+ parsed = JSON.parse(rawBody);
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ if (!Array.isArray(parsed)) {
176
+ if (!isLocalToolCall(parsed))
177
+ return undefined;
178
+ const response = await runLocalToolCall(parsed, ctx);
179
+ // A notification produced no response; there is still nothing to forward.
180
+ return response
181
+ ? { localResponse: JSON.stringify(response), localIds: [parsed.id ?? null] }
182
+ : { localIds: [] };
183
+ }
184
+ const localIdx = parsed.map((m, i) => (isLocalToolCall(m) ? i : -1)).filter((i) => i >= 0);
185
+ if (!localIdx.length)
186
+ return undefined;
187
+ const responses = [];
188
+ for (const i of localIdx) {
189
+ const r = await runLocalToolCall(parsed[i], ctx);
190
+ if (r)
191
+ responses.push(r);
192
+ }
193
+ const remainder = parsed.filter((_, i) => !localIdx.includes(i));
194
+ return {
195
+ localResponse: responses.length ? JSON.stringify(responses) : undefined,
196
+ forwardBody: remainder.length ? JSON.stringify(remainder) : undefined,
197
+ localIds: localIdx.map((i) => parsed[i]?.id ?? null),
198
+ };
199
+ }
200
+ /**
201
+ * Does this request contain a `tools/list`, whose reply we must rewrite to
202
+ * advertise the locally-served tools?
203
+ */
204
+ export function requestWantsToolsList(rawBody) {
205
+ try {
206
+ const parsed = JSON.parse(rawBody);
207
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
208
+ return messages.some((m) => m?.method === 'tools/list');
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ }
214
+ const SSE_DATA = /^data:\s?(.*)$/;
215
+ export function parseRpcEnvelope(text) {
216
+ const trimmed = text.trimStart();
217
+ // Plain JSON — the common case.
218
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
219
+ try {
220
+ const parsed = JSON.parse(text);
221
+ const wasArray = Array.isArray(parsed);
222
+ return {
223
+ messages: wasArray ? parsed : [parsed],
224
+ rebuild: (m) => JSON.stringify(wasArray ? m : (m[0] ?? null)),
225
+ };
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ }
231
+ // SSE framing: one or more `data:` lines, blank-line separated.
232
+ if (!/(^|\n)data:/.test(text))
233
+ return undefined;
234
+ const lines = text.split(/\r?\n/);
235
+ const dataIdx = [];
236
+ const messages = [];
237
+ lines.forEach((line, i) => {
238
+ const m = SSE_DATA.exec(line);
239
+ if (!m)
240
+ return;
241
+ try {
242
+ messages.push(JSON.parse(m[1]));
243
+ dataIdx.push(i);
244
+ }
245
+ catch {
246
+ /* a non-JSON data line (a keepalive, say) is left exactly as it is */
247
+ }
248
+ });
249
+ if (!messages.length)
250
+ return undefined;
251
+ return {
252
+ messages,
253
+ rebuild: (next) => {
254
+ const out = [...lines];
255
+ // Rewrite the frames we parsed, in order...
256
+ next.slice(0, dataIdx.length).forEach((msg, i) => {
257
+ out[dataIdx[i]] = `data: ${JSON.stringify(msg)}`;
258
+ });
259
+ // ...and append any EXTRA messages as their own frames, so a merged-in
260
+ // local result reaches a client that negotiated a stream.
261
+ const extra = next.slice(dataIdx.length);
262
+ const tail = extra.map((msg) => `event: message\ndata: ${JSON.stringify(msg)}\n`);
263
+ return tail.length ? `${out.join('\n').replace(/\n*$/, '\n\n')}${tail.join('\n')}\n` : out.join('\n');
264
+ },
265
+ };
266
+ }
267
+ /**
268
+ * Add our tools to an upstream `tools/list` reply, so the agent can discover
269
+ * them alongside the cloud ones.
270
+ *
271
+ * Returns the text unchanged on anything unexpected — a malformed or
272
+ * error-shaped upstream reply must pass through untouched rather than be
273
+ * rewritten into something that only looks well-formed.
274
+ */
275
+ export function augmentToolsList(requestBody, responseText) {
276
+ let request;
277
+ try {
278
+ request = JSON.parse(requestBody);
279
+ }
280
+ catch {
281
+ return responseText;
282
+ }
283
+ const requests = Array.isArray(request) ? request : [request];
284
+ const listIds = new Set(requests
285
+ .filter((m) => m?.method === 'tools/list')
286
+ .map((m) => m?.id)
287
+ .filter((id) => id !== undefined));
288
+ if (!listIds.size)
289
+ return responseText;
290
+ const envelope = parseRpcEnvelope(responseText);
291
+ if (!envelope)
292
+ return responseText;
293
+ let changed = false;
294
+ for (const m of envelope.messages) {
295
+ if (!listIds.has(m?.id))
296
+ continue;
297
+ if (!Array.isArray(m?.result?.tools))
298
+ continue;
299
+ // Paginated discovery: a client that follows `nextCursor` aggregates every
300
+ // page, so appending on each one yields duplicate tool names and an
301
+ // ambiguous or rejected registration. Add them to the FINAL page only, so
302
+ // they appear exactly once across the sequence. (codex P2.)
303
+ if (m.result.nextCursor !== undefined && m.result.nextCursor !== null)
304
+ continue;
305
+ const present = new Set(m.result.tools.map((t) => t?.name));
306
+ for (const def of LOCAL_TOOL_DEFINITIONS) {
307
+ if (!present.has(def.name)) {
308
+ m.result.tools.push({ ...def });
309
+ changed = true;
310
+ }
311
+ }
312
+ }
313
+ return changed ? envelope.rebuild(envelope.messages) : responseText;
314
+ }
package/dist/mcp-proxy.js CHANGED
@@ -69,6 +69,7 @@
69
69
  * proxy serves have disjoint capabilities — see `providedSecrets` below.
70
70
  */
71
71
  import * as http from 'node:http';
72
+ import { interceptLocalTools, augmentToolsList, parseRpcEnvelope } from './local-mcp-tools.js';
72
73
  import { randomBytes, timingSafeEqual } from 'node:crypto';
73
74
  const DEFAULT_MCP_URL = 'https://services.yolo.studio';
74
75
  export function mcpUrl() {
@@ -275,7 +276,7 @@ export async function startMcpProxy(opts) {
275
276
  res.end(JSON.stringify({ error: 'missing or invalid proxy credential' }));
276
277
  return;
277
278
  }
278
- handleRequest(req, res, upstream, tokenCache.getToken, tokenCache.forceRefresh, fetchImpl, tracker, log).catch((err) => {
279
+ handleRequest(req, res, upstream, tokenCache.getToken, tokenCache.forceRefresh, fetchImpl, tracker, log, opts.localTools).catch((err) => {
279
280
  log(`yolo-bridge: local MCP proxy error: ${err instanceof Error ? err.message : String(err)}`);
280
281
  if (!res.headersSent) {
281
282
  res.writeHead(502, { 'Content-Type': 'application/json' });
@@ -495,6 +496,39 @@ function mergeRetryResponses(originalResponseText, retryResponseText, retriedIds
495
496
  return originalResponseText;
496
497
  }
497
498
  }
499
+ /**
500
+ * Answer a mixed batch whose CLOUD half could not be sent, without discarding
501
+ * the local half that already ran.
502
+ *
503
+ * The local calls have side effects — a file is uploaded by the time we get
504
+ * here — so silently 503ing the whole batch would hide a completed upload and
505
+ * invite the client to repeat it. Each un-forwardable id gets an explicit
506
+ * JSON-RPC error instead. (codex P2, gpt-5.6-sol.)
507
+ */
508
+ export function mergeLocalWithRemoteFailure(localResponseText, forwardBody, message) {
509
+ const local = (() => {
510
+ try {
511
+ const parsed = JSON.parse(localResponseText);
512
+ return Array.isArray(parsed) ? parsed : [parsed];
513
+ }
514
+ catch {
515
+ return [];
516
+ }
517
+ })();
518
+ const failures = [];
519
+ try {
520
+ const pending = JSON.parse(forwardBody ?? '[]');
521
+ for (const m of (Array.isArray(pending) ? pending : [pending])) {
522
+ if (m?.id === undefined)
523
+ continue;
524
+ failures.push({ jsonrpc: '2.0', id: m.id, error: { code: -32000, message } });
525
+ }
526
+ }
527
+ catch {
528
+ /* nothing forwardable to describe */
529
+ }
530
+ return JSON.stringify([...local, ...failures]);
531
+ }
498
532
  async function forwardOnce(upstream, method, headers, body, fetchImpl, tracker) {
499
533
  return tracker.run(async (signal) => {
500
534
  const res = await fetchImpl(`${upstream}/mcp`, { method, headers, body, signal });
@@ -568,23 +602,72 @@ function hasValidSecret(req, secret) {
568
602
  }
569
603
  return valid;
570
604
  }
571
- async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchImpl, tracker, log) {
605
+ async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchImpl, tracker, log, localTools) {
572
606
  const method = req.method ?? 'POST';
573
607
  const body = method === 'POST' || method === 'DELETE' ? await readBody(req) : undefined;
608
+ // ── Locally-served tools ──────────────────────────────────────────────────
609
+ // BEFORE `getToken()` deliberately: a local call must never mint a delegated
610
+ // CLOUD credential, and must never leave this machine. A body that contains
611
+ // no local tool call falls straight through, paying one JSON.parse.
612
+ let forwardedAfterLocal = body;
613
+ let localResponseText;
614
+ if (localTools && method === 'POST' && body) {
615
+ const intercepted = await interceptLocalTools(body, localTools);
616
+ if (intercepted) {
617
+ if (!intercepted.forwardBody) {
618
+ if (!intercepted.localResponse) {
619
+ // Every local message was a NOTIFICATION, which must not be answered.
620
+ // 202 with an empty body is what the MCP Streamable HTTP transport
621
+ // returns for a notification-only POST. (codex P2.)
622
+ res.writeHead(202);
623
+ res.end();
624
+ return;
625
+ }
626
+ // Nothing left for the cloud — answer entirely from here.
627
+ res.writeHead(200, { 'Content-Type': 'application/json' });
628
+ res.end(intercepted.localResponse);
629
+ return;
630
+ }
631
+ // A batch that mixed local and cloud tools: forward only the remainder,
632
+ // and merge our answers back in below so neither half is lost.
633
+ forwardedAfterLocal = intercepted.forwardBody;
634
+ localResponseText = intercepted.localResponse;
635
+ }
636
+ }
574
637
  const headers = { Accept: 'application/json, text/event-stream' };
575
638
  const incomingContentType = req.headers['content-type'];
576
639
  if (typeof incomingContentType === 'string')
577
640
  headers['Content-Type'] = incomingContentType;
578
641
  else if (body)
579
642
  headers['Content-Type'] = 'application/json';
580
- let forwardedBody = body;
581
- if (method === 'POST' && body) {
643
+ // ⚠️ DO NOT narrow the Accept header to make the rewrites below easier.
644
+ //
645
+ // An earlier attempt asked for `application/json` only on requests whose
646
+ // response gets rewritten, to dodge SSE framing. That would have broken tool
647
+ // discovery outright: the MCP SDK's
648
+ // `WebStandardStreamableHTTPServerTransport.handlePostRequest` returns 406
649
+ // unless the client accepts BOTH `application/json` and `text/event-stream`
650
+ // (verified in the installed SDK 1.29.0, not assumed — codex P1,
651
+ // gpt-5.6-sol). The rewrites handle both shapes instead; see
652
+ // `parseRpcEnvelope`.
653
+ let forwardedBody = forwardedAfterLocal;
654
+ if (method === 'POST' && forwardedAfterLocal) {
582
655
  try {
583
656
  const token = await getToken();
584
- forwardedBody = injectToken(body, token);
657
+ forwardedBody = injectToken(forwardedAfterLocal, token);
585
658
  }
586
659
  catch (err) {
587
660
  log(`yolo-bridge: MCP token unavailable: ${err instanceof Error ? err.message : String(err)}`);
661
+ // The local half of a mixed batch has ALREADY RUN by this point — the
662
+ // file is uploaded. Dropping its reply would leave the client never
663
+ // learning the assetId and retrying the batch, uploading the same file
664
+ // again. So answer with what actually happened: local results, plus an
665
+ // explicit error for each id we could not forward. (codex P2.)
666
+ if (localResponseText) {
667
+ res.writeHead(200, { 'Content-Type': 'application/json' });
668
+ res.end(mergeLocalWithRemoteFailure(localResponseText, forwardedAfterLocal, 'MCP token unavailable'));
669
+ return;
670
+ }
588
671
  res.writeHead(503, { 'Content-Type': 'application/json' });
589
672
  res.end(JSON.stringify({ error: 'MCP token unavailable', code: 'MCP_TOKEN_UNAVAILABLE', retryable: true }));
590
673
  return;
@@ -597,7 +680,12 @@ async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchIm
597
680
  // isUnauthorizedToolResult's doc comment) — a real 401 from this upstream
598
681
  // has never actually been observed; the in-band case is the one that
599
682
  // matters in practice.
600
- if (method === 'POST' && body && (result.status === 401 || isUnauthorizedToolResult(result.text))) {
683
+ // ⚠️ Every path below uses `forwardedAfterLocal`, NEVER `body`. The original
684
+ // still contains any locally-intercepted call, and re-forwarding it on a
685
+ // retry would send the operator's local file path to the cloud — breaking the
686
+ // local-only invariant on the one path that skips the interception. (codex
687
+ // P1, gpt-5.6-sol.)
688
+ if (method === 'POST' && forwardedAfterLocal && (result.status === 401 || isUnauthorizedToolResult(result.text))) {
601
689
  log(`yolo-bridge: MCP upstream reported an invalid/expired token (status ${result.status}), force-refreshing`);
602
690
  try {
603
691
  const refreshed = await forceRefresh();
@@ -607,7 +695,7 @@ async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchIm
607
695
  // 200-with-mixed-results batch (Codex review, 2026-08-24, round 20)
608
696
  // needs the narrower partial-batch retry below -- see
609
697
  // `buildUnauthorizedRetryBatch`'s doc comment.
610
- const retryBatch = result.status === 401 ? null : buildUnauthorizedRetryBatch(body, result.text);
698
+ const retryBatch = result.status === 401 ? null : buildUnauthorizedRetryBatch(forwardedAfterLocal, result.text);
611
699
  if (retryBatch) {
612
700
  const reinjected = injectToken(retryBatch.requestSubset, refreshed);
613
701
  const retryResult = await forwardOnce(upstream, method, headers, reinjected, fetchImpl, tracker);
@@ -631,7 +719,7 @@ async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchIm
631
719
  : { ...result, text: mergedText };
632
720
  }
633
721
  else {
634
- const reinjected = injectToken(body, refreshed);
722
+ const reinjected = injectToken(forwardedAfterLocal, refreshed);
635
723
  result = await forwardOnce(upstream, method, headers, reinjected, fetchImpl, tracker);
636
724
  }
637
725
  }
@@ -639,10 +727,56 @@ async function handleRequest(req, res, upstream, getToken, forceRefresh, fetchIm
639
727
  log(`yolo-bridge: MCP token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
640
728
  }
641
729
  }
730
+ let outText = result.text;
731
+ // A `tools/list` reply from the cloud does not know about the tools this
732
+ // daemon serves itself, so add them — otherwise the agent can only call them
733
+ // by guessing they exist.
734
+ if (localTools && method === 'POST' && forwardedAfterLocal) {
735
+ outText = augmentToolsList(forwardedAfterLocal, outText);
736
+ }
737
+ // Re-join the halves of a mixed batch. Order does not matter to a JSON-RPC
738
+ // client (ids correlate the replies), but LOSING one half would.
739
+ //
740
+ // ⚠️ STATUS MATTERS AS MUCH AS BODY. The local half already uploaded a file.
741
+ // If we propagate a non-2xx from the cloud half, a client may reject the whole
742
+ // HTTP response without reading it and retry the batch — uploading the same
743
+ // file twice. So once a local result exists, this answers 200 and reports the
744
+ // cloud failure per-id inside the body. (codex P1, gpt-5.6-sol.)
745
+ let outStatus = result.status;
746
+ if (localResponseText) {
747
+ let merged;
748
+ if (result.status >= 200 && result.status < 300) {
749
+ // The upstream half may be plain JSON or an SSE stream — it chooses. The
750
+ // envelope puts our local results back in whichever shape the client is
751
+ // already reading, instead of discarding the cloud half on a failed
752
+ // JSON.parse (codex P2).
753
+ const envelope = parseRpcEnvelope(outText);
754
+ if (envelope) {
755
+ try {
756
+ const local = JSON.parse(localResponseText);
757
+ merged = envelope.rebuild([
758
+ ...envelope.messages,
759
+ ...(Array.isArray(local) ? local : [local]),
760
+ ]);
761
+ }
762
+ catch {
763
+ merged = undefined;
764
+ }
765
+ }
766
+ }
767
+ outText = merged
768
+ ?? mergeLocalWithRemoteFailure(localResponseText, forwardedAfterLocal, `upstream MCP request failed (status ${result.status})`);
769
+ outStatus = 200;
770
+ }
642
771
  const outHeaders = {};
643
772
  const contentType = result.headers.get('content-type');
644
773
  if (contentType)
645
774
  outHeaders['Content-Type'] = contentType;
646
- res.writeHead(result.status, outHeaders);
647
- res.end(result.text);
775
+ // Only force JSON when we could NOT preserve the upstream's shape (the
776
+ // failure-merge path emits a plain array). A successful merge keeps whatever
777
+ // framing the upstream chose, so the header must keep matching it.
778
+ if (localResponseText && !outText.includes('data:'))
779
+ outHeaders['Content-Type'] = 'application/json';
780
+ res.writeHead(outStatus, outHeaders);
781
+ res.end(outText);
648
782
  }
package/dist/share-cmd.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import { createReadStream } from 'node:fs';
16
16
  import { stat } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
- import { presignShare, finalizeShare, YoloBridgeApiError, } from './api-client.js';
18
+ import { presignShare, finalizeShare, deliverShare, YoloBridgeApiError, } from './api-client.js';
19
19
  import { loadAuth, loadAttachment } from './config-store.js';
20
20
  /**
21
21
  * Mirrors common-api's `MAX_ASSET_BYTES`. The server is authoritative and
@@ -147,7 +147,22 @@ export async function shareFile(rawPath, deps) {
147
147
  }
148
148
  const finalized = await finalizeShare(cfg, deps.workspaceId, deps.attachmentId, presigned.assetId);
149
149
  write(`Shared ${file.filename} → ${finalized.assetId}`);
150
- return finalized;
150
+ if (!deps.targetTileId)
151
+ return finalized;
152
+ // The upload has ALREADY SUCCEEDED. If delivery fails the file is genuinely
153
+ // in the workspace, so say that rather than reporting a flat failure and
154
+ // inviting a re-upload of something already there.
155
+ try {
156
+ const delivered = await deliverShare(cfg, deps.workspaceId, deps.attachmentId, finalized.assetId, deps.targetTileId);
157
+ write(`Delivered to ${deps.targetTileId} at ${delivered.path}`);
158
+ return { ...finalized, deliveredPath: delivered.path };
159
+ }
160
+ catch (err) {
161
+ const why = err instanceof YoloBridgeApiError ? err.message : err?.message || 'unknown error';
162
+ throw new ShareError(`${file.filename} was uploaded (asset ${finalized.assetId}) but could not be delivered to `
163
+ + `${deps.targetTileId}: ${why}. The file IS in the workspace — retry the DELIVERY only, with `
164
+ + `\`yolo-bridge deliver ${finalized.assetId} --to ${deps.targetTileId}\`, rather than sharing it again.`);
165
+ }
151
166
  }
152
167
  /** Turn an API error into something the operator can act on. */
153
168
  export function describeShareFailure(err) {
@@ -174,6 +189,44 @@ export function describeShareFailure(err) {
174
189
  * the config store rather than from daemon memory — the same credential the
175
190
  * running daemon uses, and the only one the upload routes accept (Boundary B).
176
191
  */
192
+ /**
193
+ * The preconditions every daemon-credentialed command shares: logged in,
194
+ * attached, attached to the EXPECTED workspace, and holding a scoped token.
195
+ *
196
+ * Factored out so `runShare` and `runDeliver` cannot drift apart on which
197
+ * checks they perform or what they say when one fails.
198
+ */
199
+ function resolveShareIdentity(deps) {
200
+ if (!loadAuth(deps.env, deps.io)) {
201
+ return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
202
+ }
203
+ const attachment = loadAttachment(deps.env, deps.io);
204
+ if (!attachment) {
205
+ return { ok: false, reason: 'not-attached', message: 'No active attachment — run `yolo-bridge attach` first.' };
206
+ }
207
+ if (deps.expectedWorkspaceId && attachment.workspaceId !== deps.expectedWorkspaceId) {
208
+ return {
209
+ ok: false,
210
+ reason: 'workspace-changed',
211
+ message: 'This machine is now attached to a different workspace than the one this request was '
212
+ + 'authorised against. Nothing was sent. Re-run `yolo-bridge attach` or retry.',
213
+ };
214
+ }
215
+ if (!attachment.scopedToken) {
216
+ return {
217
+ ok: false,
218
+ reason: 'no-scoped-credential',
219
+ message: 'No workspace-scoped credential is stored for this attachment, so files cannot be shared '
220
+ + 'from this machine. Run `yolo-bridge attach` to reconnect.',
221
+ };
222
+ }
223
+ return {
224
+ ok: true,
225
+ cfg: { commonApiBaseUrl: deps.commonApiBaseUrl, accessToken: attachment.scopedToken, fetchImpl: deps.fetchImpl },
226
+ workspaceId: attachment.workspaceId,
227
+ attachmentId: attachment.attachmentId,
228
+ };
229
+ }
177
230
  export async function runShare(rawPath, deps) {
178
231
  // Same precondition ordering as detach: `auth.json` is what makes this a
179
232
  // set-up machine, and its absence has a far better remedy to offer than a
@@ -185,6 +238,14 @@ export async function runShare(rawPath, deps) {
185
238
  if (!attachment) {
186
239
  return { ok: false, reason: 'not-attached', message: 'No active attachment — run `yolo-bridge attach` first.' };
187
240
  }
241
+ if (deps.expectedWorkspaceId && attachment.workspaceId !== deps.expectedWorkspaceId) {
242
+ return {
243
+ ok: false,
244
+ reason: 'workspace-changed',
245
+ message: 'This machine is now attached to a different workspace than the one this request was '
246
+ + 'authorised against. Nothing was sent. Re-run `yolo-bridge attach` or retry.',
247
+ };
248
+ }
188
249
  const scopedToken = attachment.scopedToken;
189
250
  if (!scopedToken) {
190
251
  // Nothing on this machine can mint one for an existing attachment, so say
@@ -202,14 +263,37 @@ export async function runShare(rawPath, deps) {
202
263
  fetchImpl: deps.fetchImpl,
203
264
  };
204
265
  try {
205
- const { assetId } = await shareFile(rawPath, {
266
+ const { assetId, deliveredPath } = await shareFile(rawPath, {
206
267
  cfg,
207
268
  workspaceId: attachment.workspaceId,
208
269
  attachmentId: attachment.attachmentId,
270
+ targetTileId: deps.targetTileId,
209
271
  fetchImpl: deps.fetchImpl,
210
272
  write: deps.write,
211
273
  });
212
- return { ok: true, assetId };
274
+ return { ok: true, assetId, ...(deliveredPath ? { deliveredPath } : {}) };
275
+ }
276
+ catch (err) {
277
+ return { ok: false, reason: 'error', message: describeShareFailure(err) };
278
+ }
279
+ }
280
+ /**
281
+ * Deliver an ALREADY-SHARED asset into a tile's session, without re-uploading.
282
+ *
283
+ * WHY THIS EXISTS: `shareFile` tells the operator, after a delivery failure,
284
+ * that the file IS in the workspace and to retry the delivery rather than the
285
+ * upload. That instruction was false until this existed — every entry point
286
+ * began with a fresh presign, so the only available "retry" duplicated the
287
+ * asset. (codex P2, gpt-5.6-sol.) An error message must name a recovery the
288
+ * caller can actually perform.
289
+ */
290
+ export async function runDeliver(assetId, targetTileId, deps) {
291
+ const preflight = await resolveShareIdentity(deps);
292
+ if (!preflight.ok)
293
+ return preflight;
294
+ try {
295
+ const delivered = await deliverShare(preflight.cfg, preflight.workspaceId, preflight.attachmentId, assetId, targetTileId);
296
+ return { ok: true, path: delivered.path };
213
297
  }
214
298
  catch (err) {
215
299
  return { ok: false, reason: 'error', message: describeShareFailure(err) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.19.0",
3
+ "version": "0.21.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",