@yolo-labs/yolobridge 0.16.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.
@@ -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':
@@ -412,10 +412,16 @@ export function computeUsedRows(term) {
412
412
  }
413
413
  return used;
414
414
  }
415
- /** The PTY's grid, which the tile must render at EXACTLY (it cannot be
416
- * resized — the human at the keyboard is watching the same PTY), plus how
415
+ /** The PTY's CURRENT grid, which the tile must render at EXACTLY, plus how
417
416
  * much of that grid is in USE.
418
417
  *
418
+ * The viewer must not resize this grid — the human at the keyboard is
419
+ * watching the same PTY, so a cloud-side resize would reach over and reflow
420
+ * their real screen. But the human themselves CAN resize it, and when they
421
+ * do the ring restarts under a fresh epoch (see `handleLocalResize`), so a
422
+ * viewer holding the old geometry re-seeds rather than replaying old bytes
423
+ * against a grid that no longer exists.
424
+ *
419
425
  * ⚠️ `usedRows` IS NOT A SECOND GEOMETRY. It never changes `cols`/`rows` and
420
426
  * the viewer never resizes its terminal to it: a viewer's terminal that is
421
427
  * not exactly `cols`×`rows` cannot replay the daemon's bytes (relative cursor
@@ -679,6 +685,12 @@ export function startLocalAgent(opts = {}) {
679
685
  // explicitly to disable stdin piping entirely, distinct from omitting the
680
686
  // field (which defaults to wiring up the real `process.stdin`).
681
687
  const inStream = 'stdin' in opts ? opts.stdin : process.stdin;
688
+ // Same defaulting rule as `resolveCols`/`resolveRows`: only reach for the real
689
+ // `process.stdout` when nothing was injected, so a test with a fake sink does
690
+ // not silently pick up the CI runner's terminal.
691
+ const resizeSource = 'resizeSource' in opts
692
+ ? opts.resizeSource
693
+ : (opts.stdout === undefined ? process.stdout : undefined);
682
694
  const ptyProcess = spawnImpl(agentBin, agentArgs, {
683
695
  name: 'xterm-256color',
684
696
  cols,
@@ -706,8 +718,14 @@ export function startLocalAgent(opts = {}) {
706
718
  writeChain: Promise.resolve(),
707
719
  stdin: inStream,
708
720
  rawModeEnabled: false,
721
+ resizeSource,
709
722
  };
710
723
  current = state;
724
+ if (resizeSource && typeof resizeSource.on === 'function') {
725
+ const resizeListener = () => handleLocalResize(state);
726
+ state.resizeListener = resizeListener;
727
+ resizeSource.on('resize', resizeListener);
728
+ }
711
729
  ptyProcess.onData((data) => {
712
730
  state.lastOutputAt = Date.now();
713
731
  outStream.write(data);
@@ -756,7 +774,53 @@ export function startLocalAgent(opts = {}) {
756
774
  * exit code 124. With `stdin.pause()` added below, the same repro exits
757
775
  * cleanly on its own well under a second — no timeout/kill needed.
758
776
  */
777
+ /**
778
+ * The human at the keyboard resized their terminal window.
779
+ *
780
+ * WHY THIS EXISTS: the PTY size used to be treated as fixed at spawn, on the
781
+ * reasoning that "nothing resizes this PTY, least of all the cloud". The cloud
782
+ * half of that is right and still enforced — a viewer must never reach over and
783
+ * reflow the operator's real screen. The other half was simply wrong: the human
784
+ * watching this PTY through `process.stdout` can drag their window, Node raises
785
+ * `'resize'` on SIGWINCH, and we ignored it. The child kept rendering to the old
786
+ * grid, so the operator's own terminal garbled and the tile kept reporting a
787
+ * geometry that no longer matched anything.
788
+ *
789
+ * Everything retained in the ring was captured against a grid that no longer
790
+ * exists — relative cursor moves and the scroll region are defined against the
791
+ * REAL grid, so replaying those bytes at the new size garbles rather than
792
+ * degrades. A fresh epoch is the honest answer: the viewer re-seeds from the
793
+ * current buffer instead of splicing two geometries together.
794
+ */
795
+ function handleLocalResize(state) {
796
+ const src = state.resizeSource;
797
+ if (!src)
798
+ return;
799
+ const cols = src.columns ?? 0;
800
+ const rows = src.rows ?? 0;
801
+ // A stdout that has stopped being a TTY (piped, or the window is gone)
802
+ // reports 0/undefined. Resizing a PTY to zero wedges the child.
803
+ if (cols < 1 || rows < 1)
804
+ return;
805
+ // SIGWINCH also fires for changes that are not a size change. Resizing to the
806
+ // size we already have would burn an epoch and force a pointless re-seed.
807
+ if (cols === state.cols && rows === state.rows)
808
+ return;
809
+ state.cols = cols;
810
+ state.rows = rows;
811
+ state.term.resize(cols, rows);
812
+ rawRing = newRawRing(rows);
813
+ try {
814
+ state.ptyProcess.resize(cols, rows);
815
+ }
816
+ catch {
817
+ // The child is already gone; onExit will clear `current`.
818
+ }
819
+ }
759
820
  function teardownStdio(state) {
821
+ if (state.resizeSource && state.resizeListener && typeof state.resizeSource.removeListener === 'function') {
822
+ state.resizeSource.removeListener('resize', state.resizeListener);
823
+ }
760
824
  const { stdin, stdinListener } = state;
761
825
  if (stdin && stdinListener && typeof stdin.removeListener === 'function') {
762
826
  stdin.removeListener('data', stdinListener);
@@ -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.16.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",