@yolo-labs/yolobridge 0.17.0 → 0.19.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/api-client.js +35 -0
- package/dist/approved-paths.js +277 -0
- package/dist/cli.js +35 -0
- package/dist/share-cmd.js +217 -0
- package/dist/status-cmd.js +12 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -268,3 +268,38 @@ async function postEvent(cfg, workspaceId, payload) {
|
|
|
268
268
|
return undefined;
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Phase 1 of a file share: ask common-api to reserve an asset and hand back a
|
|
273
|
+
* presigned PUT.
|
|
274
|
+
*
|
|
275
|
+
* Note what is NOT sent: no path. Only the basename, the mime type and the
|
|
276
|
+
* size. The operator's directory layout is not the cloud's business, and the
|
|
277
|
+
* server has no use for it.
|
|
278
|
+
*
|
|
279
|
+
* The tile is chosen SERVER-side from the attachment — there is deliberately no
|
|
280
|
+
* `tileId` parameter here, because a daemon does not get to pick.
|
|
281
|
+
*/
|
|
282
|
+
export async function presignShare(cfg, workspaceId, attachmentId, meta) {
|
|
283
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
284
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/uploads`, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify(meta),
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const { message, code } = await parseErrorBody(res);
|
|
291
|
+
throw new YoloBridgeApiError(message, res.status, code);
|
|
292
|
+
}
|
|
293
|
+
return (await res.json());
|
|
294
|
+
}
|
|
295
|
+
/** Phase 2: the bytes are in R2; ask the server to verify and seal the asset. */
|
|
296
|
+
export async function finalizeShare(cfg, workspaceId, attachmentId, assetId) {
|
|
297
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
298
|
+
const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/uploads/${encodeURIComponent(assetId)}/finalize`, { method: 'POST', headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' }, body: '{}' });
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
const { message, code } = await parseErrorBody(res);
|
|
301
|
+
throw new YoloBridgeApiError(message, res.status, code);
|
|
302
|
+
}
|
|
303
|
+
const body = (await res.json());
|
|
304
|
+
return { assetId: body?.asset?.assetId ?? assetId };
|
|
305
|
+
}
|
|
@@ -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
|
@@ -22,6 +22,8 @@ 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';
|
|
26
|
+
import { runAllow } from './approved-paths.js';
|
|
25
27
|
import { runDetach } from './detach-cmd.js';
|
|
26
28
|
import { getStatus, formatStatus } from './status-cmd.js';
|
|
27
29
|
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
@@ -99,6 +101,11 @@ function printHelp() {
|
|
|
99
101
|
' RESUMES it (same tile) instead of adding a duplicate; --fresh skips',
|
|
100
102
|
' that check entirely.',
|
|
101
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.',
|
|
107
|
+
' share <path> Share a local file with the attached workspace, so a cloud',
|
|
108
|
+
' agent can see it. Push only — nothing reads your disk remotely.',
|
|
102
109
|
' status Print local login/attach state.',
|
|
103
110
|
' version Print the installed yolo-bridge version (also --version, -v).',
|
|
104
111
|
' --help Print this help.',
|
|
@@ -547,6 +554,30 @@ function cmdStatus() {
|
|
|
547
554
|
process.stdout.write(`${formatStatus(getStatus())}\n`);
|
|
548
555
|
return 0;
|
|
549
556
|
}
|
|
557
|
+
function cmdAllow(args) {
|
|
558
|
+
const result = runAllow(args);
|
|
559
|
+
if (!result.ok) {
|
|
560
|
+
process.stderr.write(`yolo-bridge allow: ${result.message}\n`);
|
|
561
|
+
return 1;
|
|
562
|
+
}
|
|
563
|
+
process.stdout.write(`${result.lines.join('\n')}\n`);
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
async function cmdShare(args) {
|
|
567
|
+
const rawPath = args[0];
|
|
568
|
+
if (!rawPath || rawPath.startsWith('-')) {
|
|
569
|
+
process.stderr.write('yolo-bridge share: a file path is required.\n\n yolo-bridge share ./cut.mp4\n');
|
|
570
|
+
return 64;
|
|
571
|
+
}
|
|
572
|
+
const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl() });
|
|
573
|
+
if (!result.ok) {
|
|
574
|
+
// Every one of these is an operator-actionable condition, not a bug, so it
|
|
575
|
+
// prints as a sentence with no stack trace.
|
|
576
|
+
process.stderr.write(`yolo-bridge share: ${result.message}\n`);
|
|
577
|
+
return 1;
|
|
578
|
+
}
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
550
581
|
async function cmdWorkspaces() {
|
|
551
582
|
const result = await runListWorkspaces({ commonApiBaseUrl: apiUrl() });
|
|
552
583
|
if (!result.ok) {
|
|
@@ -601,6 +632,10 @@ async function main() {
|
|
|
601
632
|
return cmdAttach(rest);
|
|
602
633
|
case 'detach':
|
|
603
634
|
return cmdDetach();
|
|
635
|
+
case 'allow':
|
|
636
|
+
return cmdAllow(rest);
|
|
637
|
+
case 'share':
|
|
638
|
+
return cmdShare(rest);
|
|
604
639
|
case 'status':
|
|
605
640
|
return cmdStatus();
|
|
606
641
|
case 'version':
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yolo-bridge share <path>` — hand a LOCAL file up to the attached workspace
|
|
3
|
+
* so the cloud orchestrator can see it.
|
|
4
|
+
*
|
|
5
|
+
* DIRECTION IS THE SECURITY MODEL. This is the only file path in the daemon,
|
|
6
|
+
* and it runs because the operator (or their local agent) asked for THIS file.
|
|
7
|
+
* There is no counterpart that lets the cloud name a path and have the daemon
|
|
8
|
+
* read it — that would turn a cloud-side prompt injection into a read of the
|
|
9
|
+
* operator's disk.
|
|
10
|
+
*
|
|
11
|
+
* Bytes go straight from this process to R2 via a presigned PUT. They do not
|
|
12
|
+
* pass through common-api, so a large file is not bounded by any JSON body
|
|
13
|
+
* limit, and the API never holds the operator's content.
|
|
14
|
+
*/
|
|
15
|
+
import { createReadStream } from 'node:fs';
|
|
16
|
+
import { stat } from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { presignShare, finalizeShare, YoloBridgeApiError, } from './api-client.js';
|
|
19
|
+
import { loadAuth, loadAttachment } from './config-store.js';
|
|
20
|
+
/**
|
|
21
|
+
* Mirrors common-api's `MAX_ASSET_BYTES`. The server is authoritative and
|
|
22
|
+
* refuses over-cap uploads on its own; this copy exists so a 2 GB video fails
|
|
23
|
+
* in a second with a readable message instead of after a long upload.
|
|
24
|
+
*
|
|
25
|
+
* If the server cap ever moves, the worst this stale copy produces is a local
|
|
26
|
+
* refusal of something the server would have accepted — a clear message, not a
|
|
27
|
+
* corrupt upload.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_SHARE_BYTES = 100 * 1024 * 1024;
|
|
30
|
+
const MIME_BY_EXT = {
|
|
31
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mkv': 'video/x-matroska',
|
|
32
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
33
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.heic': 'image/heic',
|
|
34
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.flac': 'audio/flac',
|
|
35
|
+
'.pdf': 'application/pdf', '.zip': 'application/zip', '.json': 'application/json',
|
|
36
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv',
|
|
37
|
+
};
|
|
38
|
+
export function guessMimeType(filename) {
|
|
39
|
+
return MIME_BY_EXT[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
|
|
40
|
+
}
|
|
41
|
+
export function formatBytes(n) {
|
|
42
|
+
if (n < 1024)
|
|
43
|
+
return `${n} B`;
|
|
44
|
+
if (n < 1024 * 1024)
|
|
45
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
46
|
+
if (n < 1024 * 1024 * 1024)
|
|
47
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
48
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
49
|
+
}
|
|
50
|
+
/** Raised for a condition the operator can act on. The CLI prints `.message`
|
|
51
|
+
* and exits non-zero — no stack trace, because none of these are bugs. */
|
|
52
|
+
export class ShareError extends Error {
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Everything decided from the local filesystem, BEFORE a single byte moves.
|
|
56
|
+
*
|
|
57
|
+
* Split out from the upload so the refusals are testable without a network,
|
|
58
|
+
* and so an over-cap file costs a `stat`, not an upload.
|
|
59
|
+
*/
|
|
60
|
+
export async function inspectLocalFile(rawPath) {
|
|
61
|
+
const absolutePath = path.resolve(rawPath);
|
|
62
|
+
let info;
|
|
63
|
+
try {
|
|
64
|
+
info = await stat(absolutePath);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const code = err?.code;
|
|
68
|
+
if (code === 'ENOENT')
|
|
69
|
+
throw new ShareError(`No such file: ${rawPath}`);
|
|
70
|
+
if (code === 'EACCES')
|
|
71
|
+
throw new ShareError(`Permission denied reading ${rawPath}`);
|
|
72
|
+
throw new ShareError(`Could not read ${rawPath}: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
if (info.isDirectory()) {
|
|
75
|
+
throw new ShareError(`${rawPath} is a directory. Share a single file.`);
|
|
76
|
+
}
|
|
77
|
+
if (!info.isFile()) {
|
|
78
|
+
throw new ShareError(`${rawPath} is not a regular file.`);
|
|
79
|
+
}
|
|
80
|
+
if (info.size > MAX_SHARE_BYTES) {
|
|
81
|
+
// Name both numbers: "too large" without the cap leaves the operator
|
|
82
|
+
// guessing how much to trim.
|
|
83
|
+
throw new ShareError(`${path.basename(absolutePath)} is ${formatBytes(info.size)}, over the ${formatBytes(MAX_SHARE_BYTES)} limit for a shared file.`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
absolutePath,
|
|
87
|
+
// Only the BASENAME travels. The operator's directory layout is not the
|
|
88
|
+
// cloud's business.
|
|
89
|
+
filename: path.basename(absolutePath),
|
|
90
|
+
size: info.size,
|
|
91
|
+
mimeType: guessMimeType(absolutePath),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Presign → PUT the bytes to R2 → finalize.
|
|
96
|
+
*
|
|
97
|
+
* The PUT streams from disk rather than buffering: a 100 MB file must not
|
|
98
|
+
* become a 100 MB string in this process.
|
|
99
|
+
*/
|
|
100
|
+
export async function shareFile(rawPath, deps) {
|
|
101
|
+
const write = deps.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
102
|
+
const file = await inspectLocalFile(rawPath);
|
|
103
|
+
// One fetch for all three legs. Taking it from `deps` too means a caller can
|
|
104
|
+
// inject it once instead of having to remember to put it on `cfg` as well.
|
|
105
|
+
const cfg = { ...deps.cfg, fetchImpl: deps.cfg.fetchImpl ?? deps.fetchImpl };
|
|
106
|
+
write(`Sharing ${file.filename} (${formatBytes(file.size)})…`);
|
|
107
|
+
const presigned = await presignShare(cfg, deps.workspaceId, deps.attachmentId, {
|
|
108
|
+
filename: file.filename,
|
|
109
|
+
mimeType: file.mimeType,
|
|
110
|
+
size: file.size,
|
|
111
|
+
});
|
|
112
|
+
const fetchImpl = (deps.fetchImpl ?? deps.cfg.fetchImpl ?? fetch);
|
|
113
|
+
// Streamed from disk rather than buffered: a 100 MB file must not become a
|
|
114
|
+
// 100 MB Buffer in this process. The stream is opened lazily, so it is held
|
|
115
|
+
// in a variable and explicitly destroyed on every failure path — otherwise a
|
|
116
|
+
// rejected or refused PUT leaks the descriptor.
|
|
117
|
+
const body = createReadStream(file.absolutePath);
|
|
118
|
+
// A read failure — the file deleted, truncated or unreadable mid-upload —
|
|
119
|
+
// arrives as an 'error' EVENT, not a rejected promise. With no listener Node
|
|
120
|
+
// escalates it to an uncaughtException and takes the process down, which for
|
|
121
|
+
// a daemon sharing a file the operator just moved is a very poor trade.
|
|
122
|
+
// Captured here and reported as an ordinary failure instead.
|
|
123
|
+
let readError;
|
|
124
|
+
body.on('error', (err) => { readError = err; });
|
|
125
|
+
let put;
|
|
126
|
+
try {
|
|
127
|
+
put = await fetchImpl(presigned.uploadUrl, {
|
|
128
|
+
method: presigned.method || 'PUT',
|
|
129
|
+
headers: { ...presigned.headers, 'Content-Length': String(file.size) },
|
|
130
|
+
body: body,
|
|
131
|
+
// Node's fetch requires this for a stream body.
|
|
132
|
+
duplex: 'half',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
body.destroy();
|
|
137
|
+
throw new ShareError(`Upload failed: ${err?.message || 'network error'}`);
|
|
138
|
+
}
|
|
139
|
+
if (!put.ok) {
|
|
140
|
+
body.destroy();
|
|
141
|
+
// The presigned URL is short-lived and size-bound; both failure modes are
|
|
142
|
+
// worth naming rather than surfacing a bare status.
|
|
143
|
+
throw new ShareError(`Upload failed (HTTP ${put.status}). The link may have expired, or the file changed size while uploading. Try again.`);
|
|
144
|
+
}
|
|
145
|
+
if (readError) {
|
|
146
|
+
throw new ShareError(`Could not read ${file.filename} while uploading: ${readError.message}`);
|
|
147
|
+
}
|
|
148
|
+
const finalized = await finalizeShare(cfg, deps.workspaceId, deps.attachmentId, presigned.assetId);
|
|
149
|
+
write(`Shared ${file.filename} → ${finalized.assetId}`);
|
|
150
|
+
return finalized;
|
|
151
|
+
}
|
|
152
|
+
/** Turn an API error into something the operator can act on. */
|
|
153
|
+
export function describeShareFailure(err) {
|
|
154
|
+
if (err instanceof ShareError)
|
|
155
|
+
return err.message;
|
|
156
|
+
if (err instanceof YoloBridgeApiError) {
|
|
157
|
+
if (err.code === 'PAYLOAD_TOO_LARGE')
|
|
158
|
+
return err.message;
|
|
159
|
+
if (err.code === 'STORAGE_NOT_CONFIGURED')
|
|
160
|
+
return 'File sharing is not available on this server.';
|
|
161
|
+
if (err.code === 'WORKSPACE_LIMIT_REACHED' || err.code === 'LIMIT_REACHED')
|
|
162
|
+
return err.message;
|
|
163
|
+
if (err.status === 403)
|
|
164
|
+
return 'This daemon is not attached to that workspace any more. Re-run `yolo-bridge attach`.';
|
|
165
|
+
return err.message;
|
|
166
|
+
}
|
|
167
|
+
return err?.message || 'Share failed.';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The disk-backed entry point, mirroring `runDetach`.
|
|
171
|
+
*
|
|
172
|
+
* `share` and a running `yolo-bridge attach` are two SEPARATE processes, so
|
|
173
|
+
* this reads the attachment identity and its workspace-scoped credential from
|
|
174
|
+
* the config store rather than from daemon memory — the same credential the
|
|
175
|
+
* running daemon uses, and the only one the upload routes accept (Boundary B).
|
|
176
|
+
*/
|
|
177
|
+
export async function runShare(rawPath, deps) {
|
|
178
|
+
// Same precondition ordering as detach: `auth.json` is what makes this a
|
|
179
|
+
// set-up machine, and its absence has a far better remedy to offer than a
|
|
180
|
+
// 403 would.
|
|
181
|
+
if (!loadAuth(deps.env, deps.io)) {
|
|
182
|
+
return { ok: false, reason: 'not-logged-in', message: 'Not logged in — run `yolo-bridge login` first.' };
|
|
183
|
+
}
|
|
184
|
+
const attachment = loadAttachment(deps.env, deps.io);
|
|
185
|
+
if (!attachment) {
|
|
186
|
+
return { ok: false, reason: 'not-attached', message: 'No active attachment — run `yolo-bridge attach` first.' };
|
|
187
|
+
}
|
|
188
|
+
const scopedToken = attachment.scopedToken;
|
|
189
|
+
if (!scopedToken) {
|
|
190
|
+
// Nothing on this machine can mint one for an existing attachment, so say
|
|
191
|
+
// so plainly rather than sending an account token to be refused.
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: 'no-scoped-credential',
|
|
195
|
+
message: 'No workspace-scoped credential is stored for this attachment, so files cannot be shared '
|
|
196
|
+
+ 'from this machine. Run `yolo-bridge attach` to reconnect.',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const cfg = {
|
|
200
|
+
commonApiBaseUrl: deps.commonApiBaseUrl,
|
|
201
|
+
accessToken: scopedToken,
|
|
202
|
+
fetchImpl: deps.fetchImpl,
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
const { assetId } = await shareFile(rawPath, {
|
|
206
|
+
cfg,
|
|
207
|
+
workspaceId: attachment.workspaceId,
|
|
208
|
+
attachmentId: attachment.attachmentId,
|
|
209
|
+
fetchImpl: deps.fetchImpl,
|
|
210
|
+
write: deps.write,
|
|
211
|
+
});
|
|
212
|
+
return { ok: true, assetId };
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
return { ok: false, reason: 'error', message: describeShareFailure(err) };
|
|
216
|
+
}
|
|
217
|
+
}
|
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.19.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",
|