@yolo-labs/yolobridge 0.26.0 → 0.28.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.
@@ -187,6 +187,32 @@ export async function openStream(cfg, workspaceId, attachmentId) {
187
187
  }
188
188
  return res;
189
189
  }
190
+ /**
191
+ * Tell the server where this daemon's LOCAL terminal server is listening.
192
+ *
193
+ * ⚠️ Uses the daemon's SCOPED token, and the route requires one. It is the
194
+ * mirror of the input route's rule: only a USER may type, and only the machine
195
+ * actually running the server may say where it is. A user token here would let
196
+ * anyone point a tile at an arbitrary address.
197
+ *
198
+ * Best-effort by design — returns false rather than throwing. A daemon whose
199
+ * report fails is still a perfectly good daemon; the tile simply falls back to
200
+ * the cloud relay, which is slower and works.
201
+ */
202
+ export async function reportLocalEndpoint(cfg, workspaceId, attachmentId, url, secret) {
203
+ const fetchImpl = cfg.fetchImpl ?? fetch;
204
+ try {
205
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/local-endpoint`, {
206
+ method: 'POST',
207
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ url, secret }),
209
+ });
210
+ return res.ok;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
190
216
  export async function postHeartbeat(cfg, workspaceId, attachmentId) {
191
217
  const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
192
218
  return Boolean(body?.recorded);
@@ -900,6 +900,20 @@ export async function runAttachDaemon(deps) {
900
900
  return;
901
901
  }
902
902
  await apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId);
903
+ // ⚠️ RE-REPORTED ON EVERY BEAT, not once at startup. The
904
+ // server holds this in memory with a TTL, so it is lost
905
+ // on a restart or a failover — and the correct recovery
906
+ // is the daemon simply saying it again a few seconds
907
+ // later, not the server persisting a secret. Best-effort:
908
+ // a failed report costs the tile its fast path, nothing
909
+ // more.
910
+ // Read LAZILY: the shell server starts inside
911
+ // `onAttached`, after these deps were built, so a
912
+ // snapshot taken at construction would always be empty.
913
+ const local = deps.localEndpoint?.();
914
+ if (local) {
915
+ await apiClient.reportLocalEndpoint(scopedCfg(), workspaceId, attachmentId, local.url, local.secret);
916
+ }
903
917
  }, (err) => {
904
918
  // The heartbeat hits the SAME boundary as the stream open
905
919
  // and gets the SAME 403, so it needs the same answer: a
@@ -912,6 +926,19 @@ export async function runAttachDaemon(deps) {
912
926
  detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
913
927
  });
914
928
  }, undefined, deps.timers);
929
+ // ⚠️ REPORT THE LOCAL ENDPOINT IMMEDIATELY TOO, not only on
930
+ // the interval. Waiting ~10s means a tile opened in that
931
+ // window gets a 404 and silently downgrades to the ~200ms
932
+ // relay, while a perfectly good local server is already
933
+ // listening — and it would stay downgraded for that whole
934
+ // session. Same reasoning as the immediate heartbeat below.
935
+ // (codex P2.)
936
+ {
937
+ const local = deps.localEndpoint?.();
938
+ if (local) {
939
+ apiClient.reportLocalEndpoint(scopedCfg(), workspaceId, attachmentId, local.url, local.secret).catch(() => { });
940
+ }
941
+ }
915
942
  // Send one immediately so status isn't stale for the first ~10s.
916
943
  apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => {
917
944
  if (noteCredentialRejection(err))
package/dist/cli.js CHANGED
@@ -38,9 +38,21 @@ import { getStatus, formatStatus } from './status-cmd.js';
38
38
  import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
39
39
  import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
40
40
  import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
41
+ import { startLocalShellServer } from './local-shell-server.js';
41
42
  import { buildAgentMcpArgs } from './agent-mcp-args.js';
42
43
  const DEFAULT_API_URL = 'https://api.yolo.studio';
44
+ const DEFAULT_WEBAPP_ORIGIN = 'https://yolo.studio';
43
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
+ }
44
56
  function apiUrl() {
45
57
  return process.env.YOLOBRIDGE_API_URL || DEFAULT_API_URL;
46
58
  }
@@ -326,6 +338,14 @@ async function cmdAttach(args) {
326
338
  cliVersion: readOwnVersion(),
327
339
  });
328
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;
329
349
  // argv fragment pointing the spawned agent at the local MCP proxy, or
330
350
  // `[]` when MCP isn't wired in — see `agent-mcp-args.ts`. Nothing else is
331
351
  // tracked for cleanup any more: as of 2026-08-26 `attach` writes NOTHING
@@ -336,6 +356,12 @@ async function cmdAttach(args) {
336
356
  let result;
337
357
  try {
338
358
  result = await runAttachFromDisk({
359
+ // Read lazily on each heartbeat — the shell server starts in
360
+ // `onAttached`, after this object is built, so a value here would
361
+ // always be undefined.
362
+ localEndpoint: () => (shellServerHandle
363
+ ? { url: shellServerHandle.url, secret: shellServerHandle.secret }
364
+ : undefined),
339
365
  workspaceId,
340
366
  commonApiBaseUrl: apiUrl(),
341
367
  hostLabel: attachHostInfo.hostLabel,
@@ -362,6 +388,22 @@ async function cmdAttach(args) {
362
388
  // prompt. MCP access is an enhancement on a tile that already works
363
389
  // without it; the local agent spawning is not optional.
364
390
  try {
391
+ // The local terminal server. Started BEFORE the agent, like the MCP
392
+ // proxy, so the endpoint exists by the time the tile could ask for
393
+ // it. A failure here must not stop the attach: the agent and its
394
+ // tile are the point, a local terminal is an extra.
395
+ try {
396
+ shellServerHandle = await startLocalShellServer({ allowedOrigin: webappOrigin() });
397
+ // ⚠️ THE URL, NEVER THE SECRET. This line lands in the operator's
398
+ // scrollback, which is exactly where things get copied into bug
399
+ // reports and pasted into chats. The secret authorises spawning a
400
+ // shell on this machine; it reaches the tile over the authenticated
401
+ // workspace channel and is printed nowhere.
402
+ process.stdout.write(`yolo-bridge: local terminals ready at ${shellServerHandle.url} (127.0.0.1 only)\n`);
403
+ }
404
+ catch (err) {
405
+ process.stdout.write(`yolo-bridge: local terminals unavailable (${err instanceof Error ? err.message : String(err)}) — the attach continues without them.\n`);
406
+ }
365
407
  mcpProxyHandle = await startMcpProxy({
366
408
  apiUrl: apiUrl(),
367
409
  getAccessToken,
@@ -537,6 +579,17 @@ async function cmdAttach(args) {
537
579
  // or a reboot, and every skipped run left a file that broke the
538
580
  // operator's own standalone `claude` in that directory. Nothing written
539
581
  // is nothing to clean up.
582
+ // Same "nothing left running detached" rule as the MCP proxy: a shell the
583
+ // operator opened from the workspace must not outlive the attach that
584
+ // served it. `close()` kills every session it owns.
585
+ if (shellServerHandle) {
586
+ try {
587
+ await shellServerHandle.close();
588
+ }
589
+ catch (err) {
590
+ process.stdout.write(`yolo-bridge: local terminal shutdown failed (${err instanceof Error ? err.message : String(err)}).\n`);
591
+ }
592
+ }
540
593
  if (mcpProxyHandle) {
541
594
  try {
542
595
  await mcpProxyHandle.stop();
@@ -0,0 +1,476 @@
1
+ /**
2
+ * A terminal on the operator's OWN machine, served over loopback.
3
+ *
4
+ * This is what "open terminal" in a YoloBridge tile connects to. The browser
5
+ * talks to `127.0.0.1` directly, so a keystroke never leaves the machine that
6
+ * is rendering it.
7
+ *
8
+ * WHY NOT THE CLOUD RELAY
9
+ * -----------------------
10
+ * The obvious implementation routes keystrokes browser → common-api → daemon.
11
+ * Measured from a real operator machine that is ~200ms of echo latency, to type
12
+ * into a shell running on the same laptop as the browser. SSH on a LAN is under
13
+ * 5ms; 200ms is where characters visibly trail your fingers. Measured over this
14
+ * path on that same machine: **p50 6.9ms, p95 8.0ms**, and that figure includes
15
+ * bash actually executing the command, so the transport itself is a fraction of
16
+ * it.
17
+ *
18
+ * ⚠️ THIS IS NOT THE AGENT'S PTY. `local-agent.ts` owns exactly one PTY — the
19
+ * agent `attach` spawned — and Decision Q3 ("one tile per attach") keeps it a
20
+ * singleton. This module spawns SEPARATE shells and is a Map, because "give me
21
+ * a terminal" is a different request from "show me the agent". Mixing them
22
+ * would mean every glance at a running agent shares a keyboard with it.
23
+ *
24
+ * WHY THREE BROWSER MECHANISMS ARE HANDLED, not one — each fails differently,
25
+ * and getting any of them wrong looks identical to "browsers refuse loopback":
26
+ *
27
+ * 1. MIXED CONTENT — an https page loading http:// is normally blocked;
28
+ * loopback is exempt as a potentially-trustworthy origin.
29
+ * 2. CORS — cross-origin, so an explicit allow-origin. Never `*`: that would
30
+ * let any page on the internet reach a shell on this machine.
31
+ * 3. PRIVATE NETWORK ACCESS — Chrome preflights public→private and requires
32
+ * `Access-Control-Allow-Private-Network: true` in response.
33
+ *
34
+ * Verified against Chrome 140 at default security settings (spike, 2026-08-28):
35
+ * a page on https://yolo.studio reached this successfully. Firefox 153 was
36
+ * INCONCLUSIVE headless — the fetch hung rather than being refused, most likely
37
+ * its Local Network Access prompt with nobody present to answer it. Which is
38
+ * why the client must treat "no answer" as a timeout and fall back, never wait
39
+ * forever.
40
+ *
41
+ * ⚠️ THE BACKLOG DOES NOT RECONSTRUCT A TUI SCREEN — AND NOTHING CURRENTLY
42
+ * NEEDS IT TO. Worth stating precisely, because the obvious next step here is
43
+ * speculative work.
44
+ *
45
+ * The backlog is a byte TAIL cut at a parser-safe boundary. It cannot rebuild a
46
+ * full-screen TUI that painted its layout once and then emitted more than
47
+ * `BACKLOG_CHARS` of cursor-addressed updates: a viewer joining mid-session
48
+ * would get the updates without the screen they address. The cloud path solved
49
+ * that properly — `local-agent.ts` keeps an `@xterm/headless` mirror and serves
50
+ * a serialized screen plus a mode `prologue` — and that dependency is already
51
+ * here, so doing the same looks like the natural next slice.
52
+ *
53
+ * IT IS NOT, because no client reconnects. `openLocalTerminal` establishes the
54
+ * stream exactly once; a stream that ends marks the session DEAD rather than
55
+ * retrying, and the tile never reuses a session id — every mount opens a fresh
56
+ * shell. So the replay only ever covers output produced between `/open` and
57
+ * `/stream`, a window of milliseconds inside a single connection, where a byte
58
+ * tail is exactly right.
59
+ *
60
+ * ⚠️ WHAT WOULD MAKE IT MATTER: adding session RESUMPTION — a tile that
61
+ * reattaches to its shell across a remount, a dropped stream, or a page
62
+ * refresh. That is a real feature and a reasonable one to want. The headless
63
+ * mirror is a prerequisite FOR IT, not an improvement on its own; building the
64
+ * mirror first would be solving the second half of a problem nobody has yet.
65
+ *
66
+ * SECURITY, deliberately narrow because this hands out shells:
67
+ * · bound to 127.0.0.1 ONLY — never 0.0.0.0, so it is off the local network
68
+ * entirely (verified: a connect to the machine's own LAN IP is refused at
69
+ * the TCP level, not merely firewalled);
70
+ * · a random secret per daemon run, compared in constant time;
71
+ * · one explicit allowed origin;
72
+ * · sessions are capped and idle-reaped, so a forgotten tab cannot leave
73
+ * shells accumulating forever;
74
+ * · PTY bytes are NEVER logged. They are the operator's live screen and
75
+ * include whatever they type, passwords included.
76
+ */
77
+ import * as http from 'node:http';
78
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
79
+ import { createRequire } from 'node:module';
80
+ const require = createRequire(import.meta.url);
81
+ /** How much recent output a late-joining viewer replays. */
82
+ export const BACKLOG_CHARS = 64 * 1024;
83
+ /** Shells with no viewer for this long are killed. */
84
+ export const IDLE_REAP_MS = 5 * 60_000;
85
+ /** Hard cap on concurrent shells from one daemon. */
86
+ export const MAX_SESSIONS = 8;
87
+ /** Largest single input payload accepted. */
88
+ export const MAX_INPUT_CHARS = 8192;
89
+ /**
90
+ * Trim the replay buffer to a point a terminal can safely resume from.
91
+ *
92
+ * ⚠️ A NAIVE `slice(-N)` CORRUPTS THE SCREEN, and so does guessing. The backlog
93
+ * is a raw PTY byte stream: an arbitrary cut can land inside a CSI, OSC or DCS
94
+ * sequence, and a viewer that resumes there does not get a slightly-wrong
95
+ * screen — it takes escape fragments as literal text, or applies half a mode
96
+ * change, and stays wrong forever.
97
+ *
98
+ * ⚠️ AND LOOKING *FORWARD* FOR AN ESCAPE IS NOT ENOUGH — the first version of
99
+ * this did exactly that and was wrong twice over: the introducer that put the
100
+ * stream mid-sequence may sit BEFORE the cut where a forward scan cannot see
101
+ * it, and a newline does not terminate an OSC string, so "cut at the next
102
+ * newline" can land inside one. (codex P2.)
103
+ *
104
+ * So the parser state is actually tracked. `groundCutAt` walks a minimal VT
105
+ * state machine and returns the first offset at or after `from` where the
106
+ * stream is in GROUND state — no partial sequence, no partial surrogate.
107
+ *
108
+ * This is correct to scan from index 0 because of an invariant this function
109
+ * maintains: **the backlog always begins in ground state**. Every trim cuts to
110
+ * a ground offset, so the next scan starts from one.
111
+ */
112
+ function groundCutAt(buf, from) {
113
+ let state = 'ground';
114
+ let i = 0;
115
+ // Walk to `from`, tracking state; then keep walking until ground.
116
+ while (i < buf.length) {
117
+ if (i >= from && state === 'ground') {
118
+ const code = buf.charCodeAt(i);
119
+ // Never resume on the low half of a surrogate pair.
120
+ if (!(code >= 0xdc00 && code <= 0xdfff))
121
+ return i;
122
+ }
123
+ const ch = buf[i];
124
+ const code = buf.charCodeAt(i);
125
+ switch (state) {
126
+ case 'ground':
127
+ if (code === 0x1b)
128
+ state = 'esc';
129
+ break;
130
+ case 'esc':
131
+ // `[` opens a CSI; `]`, `P`, `X`, `^`, `_` open string-terminated
132
+ // sequences (OSC/DCS/SOS/PM/APC).
133
+ if (ch === '[')
134
+ state = 'csi';
135
+ else if (ch === ']' || ch === 'P' || ch === 'X' || ch === '^' || ch === '_')
136
+ state = 'str';
137
+ // ⚠️ INTERMEDIATE BYTES (0x20-0x2F) DO NOT END THE SEQUENCE. `ESC ( B`
138
+ // — a charset designation — is three bytes, and treating `(` as the
139
+ // end marks the boundary before `B` as ground. Trimming there replays
140
+ // a bare `B` as ordinary text and silently drops the charset switch,
141
+ // which is precisely the "parser-safe boundary" this function promises
142
+ // not to do. Per ECMA-48, stay in escape until a FINAL byte
143
+ // (0x30-0x7E). (codex P2.)
144
+ else if (code >= 0x20 && code <= 0x2f) { /* intermediate — still escaping */ }
145
+ else
146
+ state = 'ground';
147
+ break;
148
+ case 'csi':
149
+ // Parameters and intermediates, terminated by a final byte 0x40-0x7E.
150
+ if (code >= 0x40 && code <= 0x7e)
151
+ state = 'ground';
152
+ break;
153
+ case 'str':
154
+ // BEL, or ST (ESC \). A newline does NOT end these — which is exactly
155
+ // what the previous newline shortcut got wrong.
156
+ if (code === 0x07)
157
+ state = 'ground';
158
+ else if (code === 0x1b && buf[i + 1] === '\\') {
159
+ state = 'ground';
160
+ i += 1;
161
+ }
162
+ break;
163
+ }
164
+ i += 1;
165
+ }
166
+ return buf.length;
167
+ }
168
+ /**
169
+ * Drop the oldest bytes, cutting only at a resumable boundary.
170
+ *
171
+ * The cut only ever moves FORWARD, so it can never resurrect bytes that were
172
+ * already meant to be gone.
173
+ */
174
+ export function trimBacklog(buf, limit = BACKLOG_CHARS) {
175
+ if (buf.length <= limit)
176
+ return buf;
177
+ return buf.slice(groundCutAt(buf, buf.length - limit));
178
+ }
179
+ function defaultSpawn() {
180
+ const pty = require('node-pty');
181
+ return ({ cols, rows }) => pty.spawn(process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/bash'), [], { name: 'xterm-256color', cols, rows, cwd: process.env.HOME, env: process.env });
182
+ }
183
+ /**
184
+ * Constant-time secret comparison that cannot throw on a length mismatch.
185
+ *
186
+ * `timingSafeEqual` throws when the buffers differ in length, and a thrown
187
+ * comparison is both a crash and a length oracle. The length check short-
188
+ * circuits first, which leaks only the length — already visible from the URL.
189
+ */
190
+ export function secretMatches(given, expected) {
191
+ if (typeof given !== 'string')
192
+ return false;
193
+ const a = Buffer.from(given);
194
+ const b = Buffer.from(expected);
195
+ if (a.length !== b.length)
196
+ return false;
197
+ return timingSafeEqual(a, b);
198
+ }
199
+ export async function startLocalShellServer(opts) {
200
+ const allowedOrigin = opts.allowedOrigin;
201
+ const spawnShell = opts.spawnShell ?? defaultSpawn();
202
+ const now = opts.now ?? Date.now;
203
+ const idleReapMs = opts.idleReapMs ?? IDLE_REAP_MS;
204
+ const maxSessions = opts.maxSessions ?? MAX_SESSIONS;
205
+ const secret = randomBytes(24).toString('hex');
206
+ const sessions = new Map();
207
+ const killSession = (s) => {
208
+ // ⚠️ RE-ENTRANT. `kill()` may fire `onExit` SYNCHRONOUSLY — the interface
209
+ // permits it and real PTYs do it — which calls straight back in here.
210
+ // Without this guard the second entry kills again, recursing until the
211
+ // stack blows. Checked BEFORE the flag is set, not after. (codex P2.)
212
+ if (s.exited)
213
+ return;
214
+ s.exited = true;
215
+ for (const v of s.viewers) {
216
+ try {
217
+ v.res.end();
218
+ }
219
+ catch { /* already gone */ }
220
+ }
221
+ s.viewers.clear();
222
+ try {
223
+ s.pty.kill();
224
+ }
225
+ catch { /* already dead */ }
226
+ sessions.delete(s.id);
227
+ };
228
+ /**
229
+ * Send one chunk to one viewer, dropping rather than queueing when it is
230
+ * behind. See `Viewer` for why.
231
+ *
232
+ * The loss is REPORTED when the socket recovers — a viewer that silently
233
+ * skipped output would render a screen that is wrong with no indication,
234
+ * which is worse than a visible gap. Same discipline as the cloud path's
235
+ * `droppedBytes`.
236
+ */
237
+ const writeToViewer = (session, v, data) => {
238
+ if (v.saturated) {
239
+ v.dropped += data.length;
240
+ return;
241
+ }
242
+ let ok = false;
243
+ try {
244
+ ok = v.res.write(`data: ${JSON.stringify(data)}\n\n`);
245
+ }
246
+ catch {
247
+ return;
248
+ }
249
+ if (ok)
250
+ return;
251
+ v.saturated = true;
252
+ v.res.once('drain', () => {
253
+ v.saturated = false;
254
+ if (v.dropped > 0) {
255
+ const n = v.dropped;
256
+ v.dropped = 0;
257
+ // Marked inline, where the gap actually happened.
258
+ try {
259
+ v.res.write(`data: ${JSON.stringify(`\r\n\u001b[33m── ${n} bytes skipped — viewer fell behind ──\u001b[0m\r\n`)}\n\n`);
260
+ }
261
+ catch { /* gone */ }
262
+ }
263
+ // Re-seed from the retained tail so the screen is coherent again rather
264
+ // than resuming mid-stream after a hole.
265
+ try {
266
+ v.res.write(`data: ${JSON.stringify(session.backlog)}\n\n`);
267
+ }
268
+ catch { /* gone */ }
269
+ });
270
+ };
271
+ const reaper = setInterval(() => {
272
+ const t = now();
273
+ for (const s of [...sessions.values()]) {
274
+ // ⚠️ Only reap shells nobody is watching. A viewer that is merely quiet
275
+ // is still a viewer — killing on output-silence would take out an idle
276
+ // shell the operator is about to type into.
277
+ if (s.viewers.size === 0 && t - s.lastViewerAtMs > idleReapMs)
278
+ killSession(s);
279
+ }
280
+ }, Math.max(1000, Math.floor(idleReapMs / 4)));
281
+ reaper.unref?.();
282
+ /** CORS + Private Network Access. See this file's header for why all three. */
283
+ const applyCors = (req, res) => {
284
+ if (req.headers.origin === allowedOrigin) {
285
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
286
+ res.setHeader('Vary', 'Origin');
287
+ }
288
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
289
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
290
+ if (req.headers['access-control-request-private-network']) {
291
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
292
+ }
293
+ };
294
+ const server = http.createServer((req, res) => {
295
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
296
+ applyCors(req, res);
297
+ if (req.method === 'OPTIONS') {
298
+ res.writeHead(204);
299
+ res.end();
300
+ return;
301
+ }
302
+ // Liveness probe. Deliberately UNAUTHENTICATED and content-free: it exists
303
+ // so a viewer can discover whether this machine is the one running the
304
+ // daemon before it has any reason to hold a secret. It reveals only that
305
+ // something is listening, which the TCP connect already revealed.
306
+ if (url.pathname === '/health') {
307
+ res.writeHead(200, { 'Content-Type': 'application/json' });
308
+ res.end(JSON.stringify({ ok: true, service: 'yolo-bridge-local-shell' }));
309
+ return;
310
+ }
311
+ if (!secretMatches(url.searchParams.get('secret'), secret)) {
312
+ res.writeHead(403, { 'Content-Type': 'application/json' });
313
+ res.end(JSON.stringify({ error: 'forbidden' }));
314
+ return;
315
+ }
316
+ if (url.pathname === '/open' && req.method === 'POST') {
317
+ if (sessions.size >= maxSessions) {
318
+ res.writeHead(429, { 'Content-Type': 'application/json' });
319
+ res.end(JSON.stringify({ error: `at most ${maxSessions} local terminals` }));
320
+ return;
321
+ }
322
+ const cols = Math.max(20, Math.min(500, Number(url.searchParams.get('cols')) || 100));
323
+ const rows = Math.max(5, Math.min(200, Number(url.searchParams.get('rows')) || 30));
324
+ const id = randomBytes(9).toString('hex');
325
+ let pty;
326
+ try {
327
+ pty = spawnShell({ cols, rows });
328
+ }
329
+ catch (err) {
330
+ // ⚠️ A THROW HERE WOULD KILL THE WHOLE DAEMON. This runs inside the
331
+ // HTTP request callback, so an unhandled exception takes the process
332
+ // down — and with it the agent PTY and every other live terminal —
333
+ // because one `$SHELL` pointed at a missing binary. Report it and
334
+ // leave everything else running. (codex P2.)
335
+ res.writeHead(500, { 'Content-Type': 'application/json' });
336
+ res.end(JSON.stringify({
337
+ error: 'could not start a shell',
338
+ detail: err instanceof Error ? err.message : String(err),
339
+ }));
340
+ return;
341
+ }
342
+ const session = { id, pty, backlog: '', viewers: new Set(), lastViewerAtMs: now(), exited: false };
343
+ // ⚠️ REGISTER FIRST, SUBSCRIBE SECOND. A short-lived shell can fire
344
+ // `onExit` the instant the callback is attached — before `sessions.set`
345
+ // would have run. `killSession` would then delete nothing, and the
346
+ // already-dead session would be inserted afterwards, unreachable (404 on
347
+ // every request) yet still holding a slot against the session cap, and
348
+ // unremovable because `killSession` returns early once `exited` is set.
349
+ // A slow leak of the one resource that is capped. (codex P1.)
350
+ sessions.set(id, session);
351
+ pty.onData((data) => {
352
+ // ⚠️ NEVER LOG THIS. It is the operator's live screen.
353
+ session.backlog = trimBacklog(session.backlog + data);
354
+ for (const viewer of session.viewers)
355
+ writeToViewer(session, viewer, data);
356
+ });
357
+ pty.onExit(() => { killSession(session); });
358
+ res.writeHead(200, { 'Content-Type': 'application/json' });
359
+ res.end(JSON.stringify({ sessionId: id, cols, rows }));
360
+ return;
361
+ }
362
+ const session = sessions.get(url.searchParams.get('session') ?? '');
363
+ if (!session || session.exited) {
364
+ res.writeHead(404, { 'Content-Type': 'application/json' });
365
+ res.end(JSON.stringify({ error: 'no such session' }));
366
+ return;
367
+ }
368
+ if (url.pathname === '/stream') {
369
+ res.writeHead(200, {
370
+ 'Content-Type': 'text/event-stream',
371
+ 'Cache-Control': 'no-cache',
372
+ Connection: 'keep-alive',
373
+ });
374
+ // ⚠️ FLUSH THE HEADERS IMMEDIATELY. Node holds them until the first
375
+ // write, so a stream opened on a shell that has not printed anything yet
376
+ // never sends its response head at all — and the client's `fetch` hangs
377
+ // waiting for it, indefinitely. A fresh shell is exactly that case.
378
+ //
379
+ // An SSE comment is the standard way to do this: legal, ignored by
380
+ // EventSource, and it doubles as a "connected" signal the client can use
381
+ // to distinguish "attached and quiet" from "never got there".
382
+ res.write(': connected\n\n');
383
+ // Replay next, so a reconnecting viewer sees the screen rather than
384
+ // waiting for the next keypress to produce output.
385
+ if (session.backlog)
386
+ res.write(`data: ${JSON.stringify(session.backlog)}\n\n`);
387
+ const viewer = { res, saturated: false, dropped: 0 };
388
+ session.viewers.add(viewer);
389
+ session.lastViewerAtMs = now();
390
+ req.on('close', () => {
391
+ session.viewers.delete(viewer);
392
+ session.lastViewerAtMs = now();
393
+ });
394
+ return;
395
+ }
396
+ if (url.pathname === '/input' && req.method === 'POST') {
397
+ // ⚠️ COLLECT BYTES, DECODE ONCE. `body += chunk` decodes each Buffer
398
+ // independently, so a multibyte character split across a TCP chunk
399
+ // boundary becomes two replacement characters — silently corrupting
400
+ // pasted or typed Unicode, depending on how the network happened to
401
+ // fragment it. (codex P2.)
402
+ const chunks = [];
403
+ let size = 0;
404
+ req.on('data', (c) => {
405
+ size += c.length;
406
+ // Bounded before parsing: an unbounded body is a memory DoS on a
407
+ // process that owns the operator's shells.
408
+ if (size > MAX_INPUT_CHARS * 4) {
409
+ req.destroy();
410
+ return;
411
+ }
412
+ chunks.push(c);
413
+ });
414
+ req.on('end', () => {
415
+ try {
416
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'))?.data;
417
+ if (typeof data === 'string' && data.length <= MAX_INPUT_CHARS)
418
+ session.pty.write(data);
419
+ }
420
+ catch { /* malformed body is not worth an error page */ }
421
+ res.writeHead(204);
422
+ res.end();
423
+ });
424
+ return;
425
+ }
426
+ if (url.pathname === '/resize' && req.method === 'POST') {
427
+ const cols = Number(url.searchParams.get('cols'));
428
+ const rows = Number(url.searchParams.get('rows'));
429
+ if (Number.isFinite(cols) && Number.isFinite(rows)) {
430
+ try {
431
+ session.pty.resize(Math.max(20, Math.min(500, cols)), Math.max(5, Math.min(200, rows)));
432
+ }
433
+ catch { /* raced exit */ }
434
+ }
435
+ res.writeHead(204);
436
+ res.end();
437
+ return;
438
+ }
439
+ if (url.pathname === '/close' && req.method === 'POST') {
440
+ killSession(session);
441
+ res.writeHead(204);
442
+ res.end();
443
+ return;
444
+ }
445
+ res.writeHead(404);
446
+ res.end();
447
+ });
448
+ await new Promise((resolve, reject) => {
449
+ server.once('error', reject);
450
+ // ⚠️ 127.0.0.1 EXPLICITLY. Omitting the host, or using '0.0.0.0', would put
451
+ // a shell on the local network.
452
+ server.listen(0, '127.0.0.1', () => resolve());
453
+ });
454
+ const addr = server.address();
455
+ const port = addr.port;
456
+ return {
457
+ url: `http://127.0.0.1:${port}`,
458
+ port,
459
+ host: addr.address,
460
+ secret,
461
+ get sessionCount() { return sessions.size; },
462
+ async close() {
463
+ clearInterval(reaper);
464
+ for (const s of [...sessions.values()])
465
+ killSession(s);
466
+ await new Promise((resolve) => {
467
+ server.close(() => resolve());
468
+ // ⚠️ REQUIRED, not belt-and-braces. `server.close()` waits for open
469
+ // connections, and an SSE stream never ends on its own — that is the
470
+ // whole point of it. Without this, closing the daemon with a terminal
471
+ // open hangs forever instead of exiting.
472
+ server.closeAllConnections?.();
473
+ });
474
+ },
475
+ };
476
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "YoloBridge \u2014 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",