@yolo-labs/yolobridge 0.25.0 → 0.27.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.
@@ -324,100 +324,3 @@ export async function deliverShare(cfg, workspaceId, attachmentId, assetId, targ
324
324
  }
325
325
  return (await res.json());
326
326
  }
327
- /**
328
- * Send raw console keystrokes toward a daemon.
329
- *
330
- * ⚠️ Authenticates as the USER, not as a daemon. The console runs on a machine
331
- * the operator is sitting at; the daemon's workspace-scoped token belongs to the
332
- * machine being typed INTO and never leaves it. The route enforces the same
333
- * thing from the other side by refusing a scoped token outright.
334
- *
335
- * Returns the server's delivery verdict rather than a bare boolean, because the
336
- * distinction is real: 'written' means a replica put the frame on the daemon's
337
- * own stream, 'relayed' means it was handed off and nobody has confirmed it.
338
- */
339
- export async function sendConsoleInput(cfg, workspaceId, attachmentId, data) {
340
- const fetchImpl = cfg.fetchImpl ?? fetch;
341
- const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/input`, {
342
- method: 'POST',
343
- headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
344
- body: JSON.stringify({ data }),
345
- });
346
- if (!res.ok) {
347
- const { message, code } = await parseErrorBody(res);
348
- throw new YoloBridgeApiError(message, res.status, code);
349
- }
350
- const body = (await res.json().catch(() => ({})));
351
- return body.delivery === 'written' ? 'written' : 'relayed';
352
- }
353
- /** Register as an output viewer, which is what makes the daemon stream at all. */
354
- export async function subscribeOutput(cfg, workspaceId, tileId, subscriptionId) {
355
- const fetchImpl = cfg.fetchImpl ?? fetch;
356
- const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/tiles/${tileId}/output/subscribe`, {
357
- method: 'POST',
358
- headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
359
- body: JSON.stringify({ subscriptionId }),
360
- });
361
- if (!res.ok) {
362
- const { message, code } = await parseErrorBody(res);
363
- throw new YoloBridgeApiError(`could not subscribe to output: ${message}`, res.status, code);
364
- }
365
- return (await res.json().catch(() => ({})));
366
- }
367
- export async function unsubscribeOutput(cfg, workspaceId, tileId, subscriptionId) {
368
- const fetchImpl = cfg.fetchImpl ?? fetch;
369
- await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/tiles/${tileId}/output/unsubscribe`, {
370
- method: 'POST',
371
- headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
372
- body: JSON.stringify({ subscriptionId }),
373
- }).catch(() => { });
374
- }
375
- /** The workspace event stream, where relayed PTY output arrives. */
376
- export async function openWorkspaceEventStream(cfg, workspaceId) {
377
- const fetchImpl = cfg.fetchImpl ?? fetch;
378
- // ⚠️ THE TOKEN GOES IN THE QUERY STRING, not the Authorization header.
379
- // `routes/events.ts` reads `req.query.token` exclusively — because browsers'
380
- // EventSource cannot send custom headers — and when it is absent the route
381
- // answers HTTP 200 with an SSE `error` frame saying "Token required". So a
382
- // header-only client is ACCEPTED by fetch and then shows nothing, forever,
383
- // with no failure anywhere to notice. (codex P1.)
384
- const url = `${base(cfg)}/v1/events/stream`
385
- + `?workspaceId=${encodeURIComponent(workspaceId)}`
386
- + `&token=${encodeURIComponent(cfg.accessToken)}`;
387
- const res = await fetchImpl(url, { headers: { ...authHeaders(cfg), Accept: 'text/event-stream' } });
388
- if (!res.ok || !res.body) {
389
- const { message, code } = await parseErrorBody(res);
390
- throw new YoloBridgeApiError(`event stream failed: ${message}`, res.status, code);
391
- }
392
- return res;
393
- }
394
- /**
395
- * Fetch the RAW replay seed for a tile — the tail of the daemon's PTY byte
396
- * stream plus the absolute offsets that say where it ends.
397
- *
398
- * ⚠️ `mode=raw`, not the default. The default returns a SERIALIZED SCREEN, which
399
- * is a rendering, not a byte stream: writing it into a terminal and then
400
- * appending live chunks puts the cursor somewhere the daemon never put it. Only
401
- * the raw form can be continued from.
402
- *
403
- * Returns undefined rather than throwing when the seed is unavailable — a
404
- * detached or briefly unreachable daemon answers 409, and a console that can
405
- * still stream live output should start blank rather than refuse to run.
406
- */
407
- export async function readRawOutputSeed(cfg, workspaceId, tileId) {
408
- const fetchImpl = cfg.fetchImpl ?? fetch;
409
- const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/tiles/${tileId}/output?mode=raw`, { headers: authHeaders(cfg) });
410
- if (!res.ok)
411
- return undefined;
412
- const body = (await res.json().catch(() => undefined));
413
- return body && typeof body.raw === 'string' ? body : undefined;
414
- }
415
- export async function resolveAttachmentTile(cfg, workspaceId, attachmentId) {
416
- const fetchImpl = cfg.fetchImpl ?? fetch;
417
- const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}`, { headers: authHeaders(cfg) });
418
- if (!res.ok)
419
- return undefined;
420
- const body = (await res.json().catch(() => ({})));
421
- const tiles = body.tiles ?? body.workspace?.tiles ?? [];
422
- return tiles.find((t) => t?.yoloBridge?.attachmentId === attachmentId)?.id;
423
- }
package/dist/cli.js CHANGED
@@ -3,13 +3,22 @@
3
3
  * `yolo-bridge` — YoloBridge local daemon CLI (docs/YOLOBRIDGE_PLAN.md,
4
4
  * Implementation Plan → build-order step 5).
5
5
  *
6
- * Subcommands:
6
+ * Subcommands (keep in step with `printHelp` — this list is what a reader of
7
+ * the file sees first, and it silently fell five commands behind before):
7
8
  * yolo-bridge login — device-authorization flow (login-cmd.ts)
8
9
  * yolo-bridge workspaces — list selectable workspaces (workspaces-cmd.ts)
9
10
  * yolo-bridge attach [workspaceId] — attach + hold the SSE stream (attach-cmd.ts)
10
11
  * (omit the id for an interactive picker)
11
12
  * yolo-bridge detach — DELETE the current attachment (detach-cmd.ts)
13
+ * yolo-bridge console — REMOVED in 0.26.0; prints where to go instead
14
+ * yolo-bridge allow <path> — approve a path the attached agent may
15
+ * send files from (approved-paths.ts)
16
+ * yolo-bridge share <path> — push a local file to the workspace,
17
+ * optionally into a tile (share-cmd.ts)
18
+ * yolo-bridge deliver <assetId> — write an ALREADY-shared file into a
19
+ * tile's session (share-cmd.ts)
12
20
  * yolo-bridge status — print local login/attach state (status-cmd.ts)
21
+ * yolo-bridge version — print the installed version (also --version, -v)
13
22
  *
14
23
  * Base URLs default to this repo's real hostnames (CLAUDE.md → Project
15
24
  * Overview): common-api `https://api.yolo.studio`, auth-service
@@ -23,16 +32,27 @@ import { hostname } from 'node:os';
23
32
  import { runLogin } from './login-cmd.js';
24
33
  import { runAttachFromDisk, pickWorkspaceFromDisk } from './attach-cmd.js';
25
34
  import { runShare, runDeliver } from './share-cmd.js';
26
- import { runConsole } from './console-cmd.js';
27
35
  import { runAllow } from './approved-paths.js';
28
36
  import { runDetach } from './detach-cmd.js';
29
37
  import { getStatus, formatStatus } from './status-cmd.js';
30
38
  import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
31
39
  import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
32
40
  import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
41
+ import { startLocalShellServer } from './local-shell-server.js';
33
42
  import { buildAgentMcpArgs } from './agent-mcp-args.js';
34
43
  const DEFAULT_API_URL = 'https://api.yolo.studio';
44
+ const DEFAULT_WEBAPP_ORIGIN = 'https://yolo.studio';
35
45
  const DEFAULT_AUTH_URL = 'https://auth.yololabs.ai';
46
+ /**
47
+ * The ONE browser origin allowed to reach the local terminal server.
48
+ *
49
+ * ⚠️ Never a wildcard: this authorises reaching a shell on the operator's
50
+ * machine, so it is a single exact origin. Overridable only for local
51
+ * development against a different webapp host.
52
+ */
53
+ function webappOrigin() {
54
+ return process.env.YOLOBRIDGE_WEBAPP_ORIGIN || DEFAULT_WEBAPP_ORIGIN;
55
+ }
36
56
  function apiUrl() {
37
57
  return process.env.YOLOBRIDGE_API_URL || DEFAULT_API_URL;
38
58
  }
@@ -105,10 +125,8 @@ function printHelp() {
105
125
  ' allow <path> Let the ATTACHED AGENT send files from this path. You type',
106
126
  ' this; nothing in the cloud can. Also --list and --remove <path>.',
107
127
  ' The daemon\'s own working directory is always allowed.',
108
- ' console [workspaceId] Attach a REAL TERMINAL to a bridged session on another',
109
- ' [--attachment <id>] machine. Ctrl+C goes to the agent; Ctrl-P Ctrl-Q leaves,',
110
- ' and the agent keeps running. Defaults to this machine\'s',
111
- ' own attachment when run with no arguments.',
128
+ ' console REMOVED in 0.26.0. Use the YoloBridge tile\'s "open terminal"',
129
+ ' control, which spawns a terminal tile wired to the session.',
112
130
  ' share <path> Share a local file with the attached workspace, so a cloud',
113
131
  ' agent can see it. Push only — nothing reads your disk remotely.',
114
132
  ' [--to <tileId>] Also write it into that tile\'s session, so its agent can open it.',
@@ -320,6 +338,14 @@ async function cmdAttach(args) {
320
338
  cliVersion: readOwnVersion(),
321
339
  });
322
340
  let mcpProxyHandle;
341
+ /**
342
+ * Serves terminals on 127.0.0.1 for the tile's "open terminal".
343
+ *
344
+ * ⚠️ SEPARATE FROM THE AGENT PTY. `startLocalAgent` owns the one agent
345
+ * session; this owns any shells the operator opens from the workspace. They
346
+ * share a lifetime — both die with the attach — and nothing else.
347
+ */
348
+ let shellServerHandle;
323
349
  // argv fragment pointing the spawned agent at the local MCP proxy, or
324
350
  // `[]` when MCP isn't wired in — see `agent-mcp-args.ts`. Nothing else is
325
351
  // tracked for cleanup any more: as of 2026-08-26 `attach` writes NOTHING
@@ -356,6 +382,22 @@ async function cmdAttach(args) {
356
382
  // prompt. MCP access is an enhancement on a tile that already works
357
383
  // without it; the local agent spawning is not optional.
358
384
  try {
385
+ // The local terminal server. Started BEFORE the agent, like the MCP
386
+ // proxy, so the endpoint exists by the time the tile could ask for
387
+ // it. A failure here must not stop the attach: the agent and its
388
+ // tile are the point, a local terminal is an extra.
389
+ try {
390
+ shellServerHandle = await startLocalShellServer({ allowedOrigin: webappOrigin() });
391
+ // ⚠️ THE URL, NEVER THE SECRET. This line lands in the operator's
392
+ // scrollback, which is exactly where things get copied into bug
393
+ // reports and pasted into chats. The secret authorises spawning a
394
+ // shell on this machine; it reaches the tile over the authenticated
395
+ // workspace channel and is printed nowhere.
396
+ process.stdout.write(`yolo-bridge: local terminals ready at ${shellServerHandle.url} (127.0.0.1 only)\n`);
397
+ }
398
+ catch (err) {
399
+ process.stdout.write(`yolo-bridge: local terminals unavailable (${err instanceof Error ? err.message : String(err)}) — the attach continues without them.\n`);
400
+ }
359
401
  mcpProxyHandle = await startMcpProxy({
360
402
  apiUrl: apiUrl(),
361
403
  getAccessToken,
@@ -531,6 +573,17 @@ async function cmdAttach(args) {
531
573
  // or a reboot, and every skipped run left a file that broke the
532
574
  // operator's own standalone `claude` in that directory. Nothing written
533
575
  // is nothing to clean up.
576
+ // Same "nothing left running detached" rule as the MCP proxy: a shell the
577
+ // operator opened from the workspace must not outlive the attach that
578
+ // served it. `close()` kills every session it owns.
579
+ if (shellServerHandle) {
580
+ try {
581
+ await shellServerHandle.close();
582
+ }
583
+ catch (err) {
584
+ process.stdout.write(`yolo-bridge: local terminal shutdown failed (${err instanceof Error ? err.message : String(err)}).\n`);
585
+ }
586
+ }
534
587
  if (mcpProxyHandle) {
535
588
  try {
536
589
  await mcpProxyHandle.stop();
@@ -596,26 +649,29 @@ function cmdStatus() {
596
649
  process.stdout.write(`${formatStatus(getStatus())}\n`);
597
650
  return 0;
598
651
  }
599
- async function cmdConsole(args) {
600
- const attIdx = args.indexOf('--attachment');
601
- const attachmentId = attIdx >= 0 ? args[attIdx + 1] : undefined;
602
- if (attIdx >= 0 && (!attachmentId || attachmentId.startsWith('-'))) {
603
- process.stderr.write('yolo-bridge console: `--attachment` needs an id.\n');
604
- return 64;
605
- }
606
- const workspaceId = args.find((a, i) => !a.startsWith('-') && i !== attIdx + 1);
607
- const result = await runConsole({ commonApiBaseUrl: apiUrl(), workspaceId, attachmentId });
608
- if (!result.ok) {
609
- process.stderr.write(`yolo-bridge console: ${result.message}\n`);
610
- return 1;
611
- }
612
- if (result.reason === 'stream-ended') {
613
- // Distinguished from a deliberate detach: the operator did not ask to
614
- // leave, so say why the session ended rather than exiting silently.
615
- process.stderr.write('yolo-bridge console: the connection ended.\n');
616
- return 1;
617
- }
618
- return 0;
652
+ /**
653
+ * REMOVED in 0.26.0 — kept as an explicit notice rather than falling through to
654
+ * "unknown command".
655
+ *
656
+ * A command that vanishes without explanation is worse than one that says why:
657
+ * anyone with it in muscle memory or a shell script gets a dead end and no
658
+ * pointer. This costs a few lines and answers the question.
659
+ */
660
+ function cmdConsoleRemoved() {
661
+ process.stderr.write([
662
+ 'yolo-bridge console was removed in 0.26.0.',
663
+ '',
664
+ 'Open a terminal from the workspace instead: the YoloBridge tile has an',
665
+ '"open terminal" control that spawns a terminal tile wired to this session.',
666
+ '',
667
+ 'It was built for reaching a session from a DIFFERENT machine\'s terminal,',
668
+ 'which turned out not to be a use case anyone had. Nothing replaced it',
669
+ 'because the tile does the job from the machine you are already on.',
670
+ '',
671
+ 'Need it back? `npm i -g @yolo-labs/yolobridge@0.25.0` still has it.',
672
+ '',
673
+ ].join('\n'));
674
+ return 64;
619
675
  }
620
676
  async function cmdDeliver(args) {
621
677
  const assetId = args.find((a) => !a.startsWith('-') && a !== args[args.indexOf('--to') + 1]);
@@ -723,7 +779,7 @@ async function main() {
723
779
  case 'allow':
724
780
  return cmdAllow(rest);
725
781
  case 'console':
726
- return cmdConsole(rest);
782
+ return cmdConsoleRemoved();
727
783
  case 'deliver':
728
784
  return cmdDeliver(rest);
729
785
  case 'share':