@yolo-labs/yolobridge 0.18.0 → 0.20.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/approved-paths.js +277 -0
- package/dist/cli.js +27 -0
- package/dist/local-mcp-tools.js +268 -0
- package/dist/mcp-proxy.js +144 -10
- package/dist/share-cmd.js +8 -0
- package/dist/status-cmd.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval list that bounds which paths the ATTACHED AGENT will send files
|
|
3
|
+
* from.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ READ THIS BEFORE RELYING ON IT: THIS IS NOT A SECURITY BOUNDARY.
|
|
6
|
+
*
|
|
7
|
+
* The attached agent runs as the SAME OS USER as this CLI and normally has
|
|
8
|
+
* shell access. It can therefore run `yolo-bridge allow /` itself, or simply
|
|
9
|
+
* edit `approved-paths.json`. The principal this list constrains can rewrite
|
|
10
|
+
* the list. No amount of care in the containment checks below changes that —
|
|
11
|
+
* they are checks against a mistake, not against an adversary who is already
|
|
12
|
+
* inside the same trust domain. (Found by codex review, gpt-5.6-sol, before
|
|
13
|
+
* this had any consumer; an earlier version of this comment claimed a
|
|
14
|
+
* "structural invariant" and was wrong.)
|
|
15
|
+
*
|
|
16
|
+
* The same reasoning cuts the other way and is why the feature is still worth
|
|
17
|
+
* having: an agent with a PTY can already `curl -T` any readable file to any
|
|
18
|
+
* host. Nothing here can stop a determined or injected agent from exfiltrating,
|
|
19
|
+
* because it never needed our upload path to do it.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS ACTUALLY BUYS, stated so nobody over-trusts it:
|
|
22
|
+
*
|
|
23
|
+
* - It stops ACCIDENTS. An agent casually sending `~/.aws/credentials`
|
|
24
|
+
* because it seemed relevant is the common case, and this prevents it.
|
|
25
|
+
* - It keeps the credentialed path deliberate. Sending through OUR upload,
|
|
26
|
+
* with the operator's workspace credential, requires an explicit act rather
|
|
27
|
+
* than being the path of least resistance.
|
|
28
|
+
* - It is an AUDIT SURFACE. Approvals are visible in `yolo-bridge status`, so
|
|
29
|
+
* a grant that was widened — by the operator or by an agent — is legible
|
|
30
|
+
* rather than silent.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IT DOES NOT BUY: protection against a cloud-originated prompt injection
|
|
33
|
+
* that instructs the agent to widen the list first. If that threat is the one
|
|
34
|
+
* that matters, the approval has to come from a channel the LOCAL AGENT cannot
|
|
35
|
+
* reach — the operator approving in their own authenticated webapp session
|
|
36
|
+
* would qualify, since an injected local agent cannot click it. That is a
|
|
37
|
+
* different design, deliberately not this one, and it still would not stop
|
|
38
|
+
* `curl`.
|
|
39
|
+
*/
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import { configDir, loadAttachment as loadAttachmentDefault, defaultIO } from './config-store.js';
|
|
43
|
+
function approvalsPath(env) {
|
|
44
|
+
return path.join(configDir(env), 'approved-paths.json');
|
|
45
|
+
}
|
|
46
|
+
export const defaultResolver = {
|
|
47
|
+
realpath(p) {
|
|
48
|
+
try {
|
|
49
|
+
return fs.realpathSync(p);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
isDirectory(p) {
|
|
56
|
+
try {
|
|
57
|
+
return fs.statSync(p).isDirectory();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Is `candidate` inside `root`?
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ BOTH SIDES MUST ALREADY BE REALPATHS. This function does no resolution —
|
|
68
|
+
* that is the caller's job — because the containment check is worthless on
|
|
69
|
+
* unresolved input: a symlink sitting inside an approved directory and pointing
|
|
70
|
+
* at `~/.ssh/id_rsa` passes a naive `startsWith` every time.
|
|
71
|
+
*
|
|
72
|
+
* The separator is not decoration either. Without it an approval of
|
|
73
|
+
* `/home/yolo/projA` also covers `/home/yolo/projA-secrets`.
|
|
74
|
+
*/
|
|
75
|
+
export function isWithinRoot(candidateRealpath, rootRealpath) {
|
|
76
|
+
if (candidateRealpath === rootRealpath)
|
|
77
|
+
return true;
|
|
78
|
+
const root = rootRealpath.endsWith(path.sep) ? rootRealpath : rootRealpath + path.sep;
|
|
79
|
+
return candidateRealpath.startsWith(root);
|
|
80
|
+
}
|
|
81
|
+
export function loadApprovals(env = process.env, io = defaultIO) {
|
|
82
|
+
const raw = io.readFile(approvalsPath(env));
|
|
83
|
+
if (!raw)
|
|
84
|
+
return [];
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (!Array.isArray(parsed?.approvals))
|
|
88
|
+
return [];
|
|
89
|
+
return parsed.approvals.filter((a) => !!a && typeof a.path === 'string' && typeof a.workspaceId === 'string');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A corrupt list must not be read as "everything is approved". Empty is the
|
|
93
|
+
// safe reading, and the operator can re-add.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function saveApprovals(approvals, env = process.env, io = defaultIO) {
|
|
98
|
+
io.writeFile(approvalsPath(env), `${JSON.stringify({ approvals }, null, 2)}\n`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Approve a path for one workspace.
|
|
102
|
+
*
|
|
103
|
+
* Resolved and existence-checked HERE rather than at send time, so the operator
|
|
104
|
+
* finds out about a typo now instead of when a send mysteriously fails.
|
|
105
|
+
*/
|
|
106
|
+
export function approvePath(rawPath, workspaceId, env = process.env, io = defaultIO, resolver = defaultResolver, now = () => new Date()) {
|
|
107
|
+
const resolved = resolver.realpath(path.resolve(rawPath));
|
|
108
|
+
if (!resolved) {
|
|
109
|
+
return { ok: false, message: `No such path: ${rawPath}` };
|
|
110
|
+
}
|
|
111
|
+
const existing = loadApprovals(env, io);
|
|
112
|
+
if (existing.some((a) => a.workspaceId === workspaceId && a.path === resolved)) {
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
alreadyPresent: true,
|
|
116
|
+
approved: existing.find((a) => a.workspaceId === workspaceId && a.path === resolved),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const approved = {
|
|
120
|
+
path: resolved,
|
|
121
|
+
raw: rawPath,
|
|
122
|
+
workspaceId,
|
|
123
|
+
approvedAt: now().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
saveApprovals([...existing, approved], env, io);
|
|
126
|
+
return { ok: true, approved, alreadyPresent: false };
|
|
127
|
+
}
|
|
128
|
+
/** Remove one approval. Matches on the RESOLVED path, so removing by the same
|
|
129
|
+
* spelling the operator approved with works even through a symlink. */
|
|
130
|
+
export function revokePath(rawPath, workspaceId, env = process.env, io = defaultIO, resolver = defaultResolver) {
|
|
131
|
+
// Match on the RESOLVED path only. Matching the stored `raw` spelling as well
|
|
132
|
+
// looked convenient and was data loss: approving `output` from two different
|
|
133
|
+
// working directories stores two distinct resolved paths with the SAME raw
|
|
134
|
+
// spelling, so revoking either removed both. (codex P2, gpt-5.6-sol.)
|
|
135
|
+
//
|
|
136
|
+
// Two candidates, because an approved directory may since have been deleted —
|
|
137
|
+
// realpath then fails, and the operator must still be able to revoke the
|
|
138
|
+
// dangling grant by its absolute path.
|
|
139
|
+
const absolute = path.resolve(rawPath);
|
|
140
|
+
const resolved = resolver.realpath(absolute);
|
|
141
|
+
const before = loadApprovals(env, io);
|
|
142
|
+
const after = before.filter((a) => {
|
|
143
|
+
if (a.workspaceId !== workspaceId)
|
|
144
|
+
return true;
|
|
145
|
+
return !(a.path === absolute || (resolved !== undefined && a.path === resolved));
|
|
146
|
+
});
|
|
147
|
+
if (after.length !== before.length)
|
|
148
|
+
saveApprovals(after, env, io);
|
|
149
|
+
return { removed: before.length - after.length };
|
|
150
|
+
}
|
|
151
|
+
export function listApprovals(workspaceId, env = process.env, io = defaultIO) {
|
|
152
|
+
return loadApprovals(env, io).filter((a) => a.workspaceId === workspaceId);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* May the AGENT send this path?
|
|
156
|
+
*
|
|
157
|
+
* A check against a MISTAKE, not against an adversary — see the module header.
|
|
158
|
+
* The agent can rewrite the list this consults.
|
|
159
|
+
*
|
|
160
|
+
* `implicitRoots` is the daemon's own working directory: the project the agent
|
|
161
|
+
* is already operating in, whose files it can read anyway. Everything else
|
|
162
|
+
* needs an explicit `yolo-bridge allow`.
|
|
163
|
+
*
|
|
164
|
+
* Fails closed on anything it cannot resolve — a path that does not exist, or a
|
|
165
|
+
* broken symlink, is not approved.
|
|
166
|
+
*/
|
|
167
|
+
export function checkPathApproved(rawPath, workspaceId, implicitRoots = [], env = process.env, io = defaultIO, resolver = defaultResolver) {
|
|
168
|
+
const resolved = resolver.realpath(path.resolve(rawPath));
|
|
169
|
+
if (!resolved)
|
|
170
|
+
return { approved: false, reason: `No such file: ${rawPath}` };
|
|
171
|
+
// Implicit roots are resolved too — the working directory can itself be
|
|
172
|
+
// reached through a symlink.
|
|
173
|
+
const roots = [];
|
|
174
|
+
for (const r of implicitRoots) {
|
|
175
|
+
const rr = resolver.realpath(path.resolve(r));
|
|
176
|
+
if (rr)
|
|
177
|
+
roots.push(rr);
|
|
178
|
+
}
|
|
179
|
+
for (const a of loadApprovals(env, io)) {
|
|
180
|
+
if (a.workspaceId === workspaceId)
|
|
181
|
+
roots.push(a.path);
|
|
182
|
+
}
|
|
183
|
+
for (const root of roots) {
|
|
184
|
+
if (isWithinRoot(resolved, root))
|
|
185
|
+
return { approved: true, root, resolvedPath: resolved };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
approved: false,
|
|
189
|
+
reason: `${path.basename(resolved)} is outside every approved path for this workspace. `
|
|
190
|
+
+ 'Approve it from your own shell with `yolo-bridge allow <path>`.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* POSIX single-quoting for a path we print back as a COPYABLE command.
|
|
195
|
+
*
|
|
196
|
+
* Media directories routinely contain spaces (`~/My Footage/`), which alone
|
|
197
|
+
* makes an unquoted suggestion wrong. The sharper reason is that a path is
|
|
198
|
+
* attacker-influenceable in a way the operator would not expect — an agent can
|
|
199
|
+
* create a directory — and an unquoted `;` in a line the operator copies runs.
|
|
200
|
+
* Single quotes are literal in POSIX shells; the `'\''` dance closes, escapes
|
|
201
|
+
* one quote, and reopens. (codex P2, gpt-5.6-sol.)
|
|
202
|
+
*/
|
|
203
|
+
export function shellQuote(value) {
|
|
204
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* `yolo-bridge allow` — add, list or remove an approved path.
|
|
208
|
+
*
|
|
209
|
+
* Requires an attachment, because an approval is scoped to ONE workspace. A
|
|
210
|
+
* machine that attaches to several workspaces should not have a grant made for
|
|
211
|
+
* one silently apply to the others.
|
|
212
|
+
*/
|
|
213
|
+
export function runAllow(args, deps = {}) {
|
|
214
|
+
const { env, io, resolver } = deps;
|
|
215
|
+
const loadAttachmentFn = deps.loadAttachmentImpl
|
|
216
|
+
?? ((e, i) => loadAttachmentDefault(e, i));
|
|
217
|
+
const attachment = loadAttachmentFn(env, io);
|
|
218
|
+
if (!attachment) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: 'not-attached',
|
|
222
|
+
message: 'No active attachment — run `yolo-bridge attach` first. Approvals are scoped to one workspace.',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const workspaceId = attachment.workspaceId;
|
|
226
|
+
if (args.includes('--list') || args.length === 0) {
|
|
227
|
+
const approvals = listApprovals(workspaceId, env, io);
|
|
228
|
+
if (!approvals.length) {
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
lines: [
|
|
232
|
+
'No approved paths for this workspace.',
|
|
233
|
+
'',
|
|
234
|
+
'The attached agent can send files from the daemon\'s working directory.',
|
|
235
|
+
'To let it send from anywhere else: yolo-bridge allow <path>',
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
lines: [
|
|
242
|
+
`Approved paths (${approvals.length}) — the attached agent may send files from these:`,
|
|
243
|
+
...approvals.map((a) => ` ${a.path}${a.raw !== a.path ? ` (added as ${a.raw})` : ''}`),
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const removeIdx = args.indexOf('--remove');
|
|
248
|
+
if (removeIdx !== -1) {
|
|
249
|
+
const target = args[removeIdx + 1];
|
|
250
|
+
if (!target)
|
|
251
|
+
return { ok: false, reason: 'error', message: '`--remove` needs a path.' };
|
|
252
|
+
const { removed } = revokePath(target, workspaceId, env, io, resolver);
|
|
253
|
+
return removed
|
|
254
|
+
? { ok: true, lines: [`Removed ${removed} approval(s) for ${target}.`] }
|
|
255
|
+
: { ok: false, reason: 'error', message: `${target} was not an approved path for this workspace.` };
|
|
256
|
+
}
|
|
257
|
+
const target = args.find((a) => !a.startsWith('-'));
|
|
258
|
+
if (!target)
|
|
259
|
+
return { ok: false, reason: 'error', message: 'A path is required.' };
|
|
260
|
+
const result = approvePath(target, workspaceId, env, io, resolver);
|
|
261
|
+
if (!result.ok)
|
|
262
|
+
return { ok: false, reason: 'error', message: result.message };
|
|
263
|
+
if (result.alreadyPresent) {
|
|
264
|
+
return { ok: true, lines: [`Already approved: ${result.approved.path}`] };
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
lines: [
|
|
269
|
+
`Approved: ${result.approved.path}`,
|
|
270
|
+
'The attached agent may now send files from here. Remove it with:',
|
|
271
|
+
// The RESOLVED path, not what was typed: a relative spelling is ambiguous
|
|
272
|
+
// across working directories, and pasting it later could revoke a
|
|
273
|
+
// different grant than the one just made.
|
|
274
|
+
` yolo-bridge allow --remove ${shellQuote(result.approved.path)}`,
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { hostname } from 'node:os';
|
|
|
23
23
|
import { runLogin } from './login-cmd.js';
|
|
24
24
|
import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
|
|
25
25
|
import { runShare } from './share-cmd.js';
|
|
26
|
+
import { runAllow } from './approved-paths.js';
|
|
26
27
|
import { runDetach } from './detach-cmd.js';
|
|
27
28
|
import { getStatus, formatStatus } from './status-cmd.js';
|
|
28
29
|
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
@@ -100,6 +101,9 @@ function printHelp() {
|
|
|
100
101
|
' RESUMES it (same tile) instead of adding a duplicate; --fresh skips',
|
|
101
102
|
' that check entirely.',
|
|
102
103
|
' detach Detach the current workspace attachment.',
|
|
104
|
+
' allow <path> Let the ATTACHED AGENT send files from this path. You type',
|
|
105
|
+
' this; nothing in the cloud can. Also --list and --remove <path>.',
|
|
106
|
+
' The daemon\'s own working directory is always allowed.',
|
|
103
107
|
' share <path> Share a local file with the attached workspace, so a cloud',
|
|
104
108
|
' agent can see it. Push only — nothing reads your disk remotely.',
|
|
105
109
|
' status Print local login/attach state.',
|
|
@@ -345,6 +349,18 @@ async function cmdAttach(args) {
|
|
|
345
349
|
// guess which studio_list_tiles row is itself — and a backwards
|
|
346
350
|
// guess sends the prompt into its OWN input.
|
|
347
351
|
callerTileId: tileId,
|
|
352
|
+
// Tools the proxy serves ITSELF (local-mcp-tools.ts). A cloud tool
|
|
353
|
+
// cannot read this machine's disk, so sharing a local file is the
|
|
354
|
+
// one thing that has to be answered here.
|
|
355
|
+
//
|
|
356
|
+
// `implicitRoots` is the directory the daemon was launched in — the
|
|
357
|
+
// project the agent is already working in and can read anyway.
|
|
358
|
+
// Anything outside needs `yolo-bridge allow`.
|
|
359
|
+
localTools: {
|
|
360
|
+
workspaceId,
|
|
361
|
+
implicitRoots: [process.cwd()],
|
|
362
|
+
commonApiBaseUrl: apiUrl(),
|
|
363
|
+
},
|
|
348
364
|
log: (line) => process.stdout.write(`${line}\n`),
|
|
349
365
|
});
|
|
350
366
|
// Command-line MCP configuration, never a file in the project
|
|
@@ -550,6 +566,15 @@ function cmdStatus() {
|
|
|
550
566
|
process.stdout.write(`${formatStatus(getStatus())}\n`);
|
|
551
567
|
return 0;
|
|
552
568
|
}
|
|
569
|
+
function cmdAllow(args) {
|
|
570
|
+
const result = runAllow(args);
|
|
571
|
+
if (!result.ok) {
|
|
572
|
+
process.stderr.write(`yolo-bridge allow: ${result.message}\n`);
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
575
|
+
process.stdout.write(`${result.lines.join('\n')}\n`);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
553
578
|
async function cmdShare(args) {
|
|
554
579
|
const rawPath = args[0];
|
|
555
580
|
if (!rawPath || rawPath.startsWith('-')) {
|
|
@@ -619,6 +644,8 @@ async function main() {
|
|
|
619
644
|
return cmdAttach(rest);
|
|
620
645
|
case 'detach':
|
|
621
646
|
return cmdDetach();
|
|
647
|
+
case 'allow':
|
|
648
|
+
return cmdAllow(rest);
|
|
622
649
|
case 'share':
|
|
623
650
|
return cmdShare(rest);
|
|
624
651
|
case 'status':
|
|
@@ -0,0 +1,268 @@
|
|
|
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, 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
|
+
},
|
|
45
|
+
required: ['path'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
function toolResult(id, text, isError = false) {
|
|
51
|
+
return {
|
|
52
|
+
jsonrpc: '2.0',
|
|
53
|
+
id,
|
|
54
|
+
result: { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Is this parsed JSON-RPC message a call to one of our local tools? */
|
|
58
|
+
export function isLocalToolCall(msg) {
|
|
59
|
+
const m = msg;
|
|
60
|
+
if (!m || m.method !== 'tools/call')
|
|
61
|
+
return false;
|
|
62
|
+
return typeof m.params?.name === 'string'
|
|
63
|
+
&& LOCAL_TOOL_DEFINITIONS.some((t) => t.name === m.params.name);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run one local tool call and produce its JSON-RPC response.
|
|
67
|
+
*
|
|
68
|
+
* Errors come back as a tool result with `isError`, not a JSON-RPC error: a
|
|
69
|
+
* refused path is a normal answer the agent should read and act on, not a
|
|
70
|
+
* protocol fault.
|
|
71
|
+
*/
|
|
72
|
+
export async function runLocalToolCall(msg, ctx) {
|
|
73
|
+
// A message with NO `id` is a notification: JSON-RPC says do the work and
|
|
74
|
+
// send nothing back. `msg.id ?? null` would have turned that into a normal
|
|
75
|
+
// reply carrying `id: null` — a spurious entry in a batch, and a body where a
|
|
76
|
+
// notification-only request expects none. An EXPLICIT null id is a different
|
|
77
|
+
// thing and still gets answered. (codex P2, gpt-5.6-sol.)
|
|
78
|
+
const isNotification = !('id' in msg);
|
|
79
|
+
const id = msg.id ?? null;
|
|
80
|
+
const name = msg.params?.name;
|
|
81
|
+
const args = msg.params?.arguments ?? {};
|
|
82
|
+
if (name !== SHARE_FILE_TOOL) {
|
|
83
|
+
return isNotification ? undefined : toolResult(id, `Unknown local tool: ${String(name)}`, true);
|
|
84
|
+
}
|
|
85
|
+
const rawPath = typeof args.path === 'string' ? args.path : '';
|
|
86
|
+
if (!rawPath)
|
|
87
|
+
return isNotification ? undefined : toolResult(id, 'A `path` is required.', true);
|
|
88
|
+
const check = (ctx.checkImpl ?? checkPathApproved)(rawPath, ctx.workspaceId, ctx.implicitRoots);
|
|
89
|
+
if (!check.approved || !check.resolvedPath) {
|
|
90
|
+
return isNotification ? undefined : toolResult(id, check.reason ?? `${rawPath} is not an approved path.`, true);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
// Property 2: open what the check RESOLVED, not what the caller passed.
|
|
94
|
+
const result = await (ctx.shareImpl ?? runShare)(check.resolvedPath, {
|
|
95
|
+
commonApiBaseUrl: ctx.commonApiBaseUrl,
|
|
96
|
+
// The approval was checked against THIS workspace. The attachment on disk
|
|
97
|
+
// can have been replaced by a second `attach` since the proxy started, so
|
|
98
|
+
// bind the upload to the same workspace or refuse. (codex P2.)
|
|
99
|
+
expectedWorkspaceId: ctx.workspaceId,
|
|
100
|
+
write: () => { },
|
|
101
|
+
});
|
|
102
|
+
if (isNotification)
|
|
103
|
+
return undefined;
|
|
104
|
+
if (!result.ok)
|
|
105
|
+
return toolResult(id, result.message, true);
|
|
106
|
+
return toolResult(id, `Shared as asset ${result.assetId}. It is now visible in the cloud workspace.`);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
return isNotification ? undefined : toolResult(id, describeShareFailure(err), true);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Split an incoming body into "answered here" and "still needs the cloud".
|
|
114
|
+
*
|
|
115
|
+
* Handles the batch form because `injectToken` already has to, and a batch that
|
|
116
|
+
* mixes a local tool with cloud tools must not lose either half.
|
|
117
|
+
*
|
|
118
|
+
* Returns `undefined` when nothing is local — the overwhelmingly common case,
|
|
119
|
+
* so the normal path pays one `JSON.parse` and nothing else.
|
|
120
|
+
*/
|
|
121
|
+
export async function interceptLocalTools(rawBody, ctx) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(rawBody);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(parsed)) {
|
|
130
|
+
if (!isLocalToolCall(parsed))
|
|
131
|
+
return undefined;
|
|
132
|
+
const response = await runLocalToolCall(parsed, ctx);
|
|
133
|
+
// A notification produced no response; there is still nothing to forward.
|
|
134
|
+
return response
|
|
135
|
+
? { localResponse: JSON.stringify(response), localIds: [parsed.id ?? null] }
|
|
136
|
+
: { localIds: [] };
|
|
137
|
+
}
|
|
138
|
+
const localIdx = parsed.map((m, i) => (isLocalToolCall(m) ? i : -1)).filter((i) => i >= 0);
|
|
139
|
+
if (!localIdx.length)
|
|
140
|
+
return undefined;
|
|
141
|
+
const responses = [];
|
|
142
|
+
for (const i of localIdx) {
|
|
143
|
+
const r = await runLocalToolCall(parsed[i], ctx);
|
|
144
|
+
if (r)
|
|
145
|
+
responses.push(r);
|
|
146
|
+
}
|
|
147
|
+
const remainder = parsed.filter((_, i) => !localIdx.includes(i));
|
|
148
|
+
return {
|
|
149
|
+
localResponse: responses.length ? JSON.stringify(responses) : undefined,
|
|
150
|
+
forwardBody: remainder.length ? JSON.stringify(remainder) : undefined,
|
|
151
|
+
localIds: localIdx.map((i) => parsed[i]?.id ?? null),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Does this request contain a `tools/list`, whose reply we must rewrite to
|
|
156
|
+
* advertise the locally-served tools?
|
|
157
|
+
*/
|
|
158
|
+
export function requestWantsToolsList(rawBody) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(rawBody);
|
|
161
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
162
|
+
return messages.some((m) => m?.method === 'tools/list');
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const SSE_DATA = /^data:\s?(.*)$/;
|
|
169
|
+
export function parseRpcEnvelope(text) {
|
|
170
|
+
const trimmed = text.trimStart();
|
|
171
|
+
// Plain JSON — the common case.
|
|
172
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(text);
|
|
175
|
+
const wasArray = Array.isArray(parsed);
|
|
176
|
+
return {
|
|
177
|
+
messages: wasArray ? parsed : [parsed],
|
|
178
|
+
rebuild: (m) => JSON.stringify(wasArray ? m : (m[0] ?? null)),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// SSE framing: one or more `data:` lines, blank-line separated.
|
|
186
|
+
if (!/(^|\n)data:/.test(text))
|
|
187
|
+
return undefined;
|
|
188
|
+
const lines = text.split(/\r?\n/);
|
|
189
|
+
const dataIdx = [];
|
|
190
|
+
const messages = [];
|
|
191
|
+
lines.forEach((line, i) => {
|
|
192
|
+
const m = SSE_DATA.exec(line);
|
|
193
|
+
if (!m)
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
messages.push(JSON.parse(m[1]));
|
|
197
|
+
dataIdx.push(i);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* a non-JSON data line (a keepalive, say) is left exactly as it is */
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
if (!messages.length)
|
|
204
|
+
return undefined;
|
|
205
|
+
return {
|
|
206
|
+
messages,
|
|
207
|
+
rebuild: (next) => {
|
|
208
|
+
const out = [...lines];
|
|
209
|
+
// Rewrite the frames we parsed, in order...
|
|
210
|
+
next.slice(0, dataIdx.length).forEach((msg, i) => {
|
|
211
|
+
out[dataIdx[i]] = `data: ${JSON.stringify(msg)}`;
|
|
212
|
+
});
|
|
213
|
+
// ...and append any EXTRA messages as their own frames, so a merged-in
|
|
214
|
+
// local result reaches a client that negotiated a stream.
|
|
215
|
+
const extra = next.slice(dataIdx.length);
|
|
216
|
+
const tail = extra.map((msg) => `event: message\ndata: ${JSON.stringify(msg)}\n`);
|
|
217
|
+
return tail.length ? `${out.join('\n').replace(/\n*$/, '\n\n')}${tail.join('\n')}\n` : out.join('\n');
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Add our tools to an upstream `tools/list` reply, so the agent can discover
|
|
223
|
+
* them alongside the cloud ones.
|
|
224
|
+
*
|
|
225
|
+
* Returns the text unchanged on anything unexpected — a malformed or
|
|
226
|
+
* error-shaped upstream reply must pass through untouched rather than be
|
|
227
|
+
* rewritten into something that only looks well-formed.
|
|
228
|
+
*/
|
|
229
|
+
export function augmentToolsList(requestBody, responseText) {
|
|
230
|
+
let request;
|
|
231
|
+
try {
|
|
232
|
+
request = JSON.parse(requestBody);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return responseText;
|
|
236
|
+
}
|
|
237
|
+
const requests = Array.isArray(request) ? request : [request];
|
|
238
|
+
const listIds = new Set(requests
|
|
239
|
+
.filter((m) => m?.method === 'tools/list')
|
|
240
|
+
.map((m) => m?.id)
|
|
241
|
+
.filter((id) => id !== undefined));
|
|
242
|
+
if (!listIds.size)
|
|
243
|
+
return responseText;
|
|
244
|
+
const envelope = parseRpcEnvelope(responseText);
|
|
245
|
+
if (!envelope)
|
|
246
|
+
return responseText;
|
|
247
|
+
let changed = false;
|
|
248
|
+
for (const m of envelope.messages) {
|
|
249
|
+
if (!listIds.has(m?.id))
|
|
250
|
+
continue;
|
|
251
|
+
if (!Array.isArray(m?.result?.tools))
|
|
252
|
+
continue;
|
|
253
|
+
// Paginated discovery: a client that follows `nextCursor` aggregates every
|
|
254
|
+
// page, so appending on each one yields duplicate tool names and an
|
|
255
|
+
// ambiguous or rejected registration. Add them to the FINAL page only, so
|
|
256
|
+
// they appear exactly once across the sequence. (codex P2.)
|
|
257
|
+
if (m.result.nextCursor !== undefined && m.result.nextCursor !== null)
|
|
258
|
+
continue;
|
|
259
|
+
const present = new Set(m.result.tools.map((t) => t?.name));
|
|
260
|
+
for (const def of LOCAL_TOOL_DEFINITIONS) {
|
|
261
|
+
if (!present.has(def.name)) {
|
|
262
|
+
m.result.tools.push({ ...def });
|
|
263
|
+
changed = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return changed ? envelope.rebuild(envelope.messages) : responseText;
|
|
268
|
+
}
|
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
|
-
|
|
581
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
647
|
-
|
|
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
|
@@ -185,6 +185,14 @@ export async function runShare(rawPath, deps) {
|
|
|
185
185
|
if (!attachment) {
|
|
186
186
|
return { ok: false, reason: 'not-attached', message: 'No active attachment — run `yolo-bridge attach` first.' };
|
|
187
187
|
}
|
|
188
|
+
if (deps.expectedWorkspaceId && attachment.workspaceId !== deps.expectedWorkspaceId) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
reason: 'workspace-changed',
|
|
192
|
+
message: 'This machine is now attached to a different workspace than the one this request was '
|
|
193
|
+
+ 'authorised against. Nothing was sent. Re-run `yolo-bridge attach` or retry.',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
188
196
|
const scopedToken = attachment.scopedToken;
|
|
189
197
|
if (!scopedToken) {
|
|
190
198
|
// Nothing on this machine can mint one for an existing attachment, so say
|
package/dist/status-cmd.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* longer invisible just because it can't be printed to the screen.
|
|
23
23
|
*/
|
|
24
24
|
import { loadAuth, loadAttachment } from './config-store.js';
|
|
25
|
+
import { listApprovals } from './approved-paths.js';
|
|
25
26
|
import { loadConnectionState, formatConnectionEvent } from './connection-state.js';
|
|
26
27
|
export function getStatus(deps = {}) {
|
|
27
28
|
const now = deps.now ?? Date.now;
|
|
@@ -31,6 +32,11 @@ export function getStatus(deps = {}) {
|
|
|
31
32
|
loggedIn: Boolean(auth),
|
|
32
33
|
attached: Boolean(attachment),
|
|
33
34
|
};
|
|
35
|
+
if (attachment) {
|
|
36
|
+
const approvals = listApprovals(attachment.workspaceId, deps.env, deps.io);
|
|
37
|
+
if (approvals.length)
|
|
38
|
+
report.approvedPaths = approvals.map((a) => a.path);
|
|
39
|
+
}
|
|
34
40
|
if (auth) {
|
|
35
41
|
report.tokenExpiresAtMs = auth.expiresAtMs;
|
|
36
42
|
report.tokenExpired = auth.expiresAtMs <= now();
|
|
@@ -78,5 +84,11 @@ export function formatStatus(report) {
|
|
|
78
84
|
}
|
|
79
85
|
}
|
|
80
86
|
}
|
|
87
|
+
if (report.approvedPaths?.length) {
|
|
88
|
+
lines.push(`Agent may send files from (${report.approvedPaths.length} approved path(s)):`);
|
|
89
|
+
for (const p of report.approvedPaths)
|
|
90
|
+
lines.push(` ${p}`);
|
|
91
|
+
lines.push('(plus the daemon\'s working directory. Change with `yolo-bridge allow`.)');
|
|
92
|
+
}
|
|
81
93
|
return lines.join('\n');
|
|
82
94
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|