@yolo-labs/yolobridge 0.17.0 → 0.18.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/cli.js +20 -0
- package/dist/share-cmd.js +217 -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
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +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
26
|
import { runDetach } from './detach-cmd.js';
|
|
26
27
|
import { getStatus, formatStatus } from './status-cmd.js';
|
|
27
28
|
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
@@ -99,6 +100,8 @@ function printHelp() {
|
|
|
99
100
|
' RESUMES it (same tile) instead of adding a duplicate; --fresh skips',
|
|
100
101
|
' that check entirely.',
|
|
101
102
|
' detach Detach the current workspace attachment.',
|
|
103
|
+
' share <path> Share a local file with the attached workspace, so a cloud',
|
|
104
|
+
' agent can see it. Push only — nothing reads your disk remotely.',
|
|
102
105
|
' status Print local login/attach state.',
|
|
103
106
|
' version Print the installed yolo-bridge version (also --version, -v).',
|
|
104
107
|
' --help Print this help.',
|
|
@@ -547,6 +550,21 @@ function cmdStatus() {
|
|
|
547
550
|
process.stdout.write(`${formatStatus(getStatus())}\n`);
|
|
548
551
|
return 0;
|
|
549
552
|
}
|
|
553
|
+
async function cmdShare(args) {
|
|
554
|
+
const rawPath = args[0];
|
|
555
|
+
if (!rawPath || rawPath.startsWith('-')) {
|
|
556
|
+
process.stderr.write('yolo-bridge share: a file path is required.\n\n yolo-bridge share ./cut.mp4\n');
|
|
557
|
+
return 64;
|
|
558
|
+
}
|
|
559
|
+
const result = await runShare(rawPath, { commonApiBaseUrl: apiUrl() });
|
|
560
|
+
if (!result.ok) {
|
|
561
|
+
// Every one of these is an operator-actionable condition, not a bug, so it
|
|
562
|
+
// prints as a sentence with no stack trace.
|
|
563
|
+
process.stderr.write(`yolo-bridge share: ${result.message}\n`);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
550
568
|
async function cmdWorkspaces() {
|
|
551
569
|
const result = await runListWorkspaces({ commonApiBaseUrl: apiUrl() });
|
|
552
570
|
if (!result.ok) {
|
|
@@ -601,6 +619,8 @@ async function main() {
|
|
|
601
619
|
return cmdAttach(rest);
|
|
602
620
|
case 'detach':
|
|
603
621
|
return cmdDetach();
|
|
622
|
+
case 'share':
|
|
623
|
+
return cmdShare(rest);
|
|
604
624
|
case 'status':
|
|
605
625
|
return cmdStatus();
|
|
606
626
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|