@yolo-labs/yolobridge 0.26.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.
- package/dist/cli.js +47 -0
- package/dist/local-shell-server.js +462 -0
- package/package.json +1 -1
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
|
|
@@ -362,6 +382,22 @@ async function cmdAttach(args) {
|
|
|
362
382
|
// prompt. MCP access is an enhancement on a tile that already works
|
|
363
383
|
// without it; the local agent spawning is not optional.
|
|
364
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
|
+
}
|
|
365
401
|
mcpProxyHandle = await startMcpProxy({
|
|
366
402
|
apiUrl: apiUrl(),
|
|
367
403
|
getAccessToken,
|
|
@@ -537,6 +573,17 @@ async function cmdAttach(args) {
|
|
|
537
573
|
// or a reboot, and every skipped run left a file that broke the
|
|
538
574
|
// operator's own standalone `claude` in that directory. Nothing written
|
|
539
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
|
+
}
|
|
540
587
|
if (mcpProxyHandle) {
|
|
541
588
|
try {
|
|
542
589
|
await mcpProxyHandle.stop();
|
|
@@ -0,0 +1,462 @@
|
|
|
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
|
+
* ⚠️ KNOWN GAP — RECONNECT FIDELITY FOR TUIs. The backlog is a byte TAIL, cut
|
|
42
|
+
* at a parser-safe boundary. That is enough to resume a shell transcript, and
|
|
43
|
+
* NOT enough to reconstruct a full-screen TUI that painted its layout once and
|
|
44
|
+
* then emitted more than `BACKLOG_CHARS` of cursor-addressed updates: a viewer
|
|
45
|
+
* reconnecting mid-session gets the updates without the screen they address,
|
|
46
|
+
* and sticky modes set before the window are lost. The cloud path already
|
|
47
|
+
* solved this properly — `local-agent.ts` keeps an `@xterm/headless` mirror and
|
|
48
|
+
* serves a serialized screen plus a mode `prologue` — and `@xterm/headless` is
|
|
49
|
+
* already a dependency here. Doing the same for these shells is the next slice;
|
|
50
|
+
* it is called out rather than left to be discovered. (codex P2.)
|
|
51
|
+
*
|
|
52
|
+
* SECURITY, deliberately narrow because this hands out shells:
|
|
53
|
+
* · bound to 127.0.0.1 ONLY — never 0.0.0.0, so it is off the local network
|
|
54
|
+
* entirely (verified: a connect to the machine's own LAN IP is refused at
|
|
55
|
+
* the TCP level, not merely firewalled);
|
|
56
|
+
* · a random secret per daemon run, compared in constant time;
|
|
57
|
+
* · one explicit allowed origin;
|
|
58
|
+
* · sessions are capped and idle-reaped, so a forgotten tab cannot leave
|
|
59
|
+
* shells accumulating forever;
|
|
60
|
+
* · PTY bytes are NEVER logged. They are the operator's live screen and
|
|
61
|
+
* include whatever they type, passwords included.
|
|
62
|
+
*/
|
|
63
|
+
import * as http from 'node:http';
|
|
64
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
65
|
+
import { createRequire } from 'node:module';
|
|
66
|
+
const require = createRequire(import.meta.url);
|
|
67
|
+
/** How much recent output a late-joining viewer replays. */
|
|
68
|
+
export const BACKLOG_CHARS = 64 * 1024;
|
|
69
|
+
/** Shells with no viewer for this long are killed. */
|
|
70
|
+
export const IDLE_REAP_MS = 5 * 60_000;
|
|
71
|
+
/** Hard cap on concurrent shells from one daemon. */
|
|
72
|
+
export const MAX_SESSIONS = 8;
|
|
73
|
+
/** Largest single input payload accepted. */
|
|
74
|
+
export const MAX_INPUT_CHARS = 8192;
|
|
75
|
+
/**
|
|
76
|
+
* Trim the replay buffer to a point a terminal can safely resume from.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ A NAIVE `slice(-N)` CORRUPTS THE SCREEN, and so does guessing. The backlog
|
|
79
|
+
* is a raw PTY byte stream: an arbitrary cut can land inside a CSI, OSC or DCS
|
|
80
|
+
* sequence, and a viewer that resumes there does not get a slightly-wrong
|
|
81
|
+
* screen — it takes escape fragments as literal text, or applies half a mode
|
|
82
|
+
* change, and stays wrong forever.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ AND LOOKING *FORWARD* FOR AN ESCAPE IS NOT ENOUGH — the first version of
|
|
85
|
+
* this did exactly that and was wrong twice over: the introducer that put the
|
|
86
|
+
* stream mid-sequence may sit BEFORE the cut where a forward scan cannot see
|
|
87
|
+
* it, and a newline does not terminate an OSC string, so "cut at the next
|
|
88
|
+
* newline" can land inside one. (codex P2.)
|
|
89
|
+
*
|
|
90
|
+
* So the parser state is actually tracked. `groundCutAt` walks a minimal VT
|
|
91
|
+
* state machine and returns the first offset at or after `from` where the
|
|
92
|
+
* stream is in GROUND state — no partial sequence, no partial surrogate.
|
|
93
|
+
*
|
|
94
|
+
* This is correct to scan from index 0 because of an invariant this function
|
|
95
|
+
* maintains: **the backlog always begins in ground state**. Every trim cuts to
|
|
96
|
+
* a ground offset, so the next scan starts from one.
|
|
97
|
+
*/
|
|
98
|
+
function groundCutAt(buf, from) {
|
|
99
|
+
let state = 'ground';
|
|
100
|
+
let i = 0;
|
|
101
|
+
// Walk to `from`, tracking state; then keep walking until ground.
|
|
102
|
+
while (i < buf.length) {
|
|
103
|
+
if (i >= from && state === 'ground') {
|
|
104
|
+
const code = buf.charCodeAt(i);
|
|
105
|
+
// Never resume on the low half of a surrogate pair.
|
|
106
|
+
if (!(code >= 0xdc00 && code <= 0xdfff))
|
|
107
|
+
return i;
|
|
108
|
+
}
|
|
109
|
+
const ch = buf[i];
|
|
110
|
+
const code = buf.charCodeAt(i);
|
|
111
|
+
switch (state) {
|
|
112
|
+
case 'ground':
|
|
113
|
+
if (code === 0x1b)
|
|
114
|
+
state = 'esc';
|
|
115
|
+
break;
|
|
116
|
+
case 'esc':
|
|
117
|
+
// `[` opens a CSI; `]`, `P`, `X`, `^`, `_` open string-terminated
|
|
118
|
+
// sequences (OSC/DCS/SOS/PM/APC).
|
|
119
|
+
if (ch === '[')
|
|
120
|
+
state = 'csi';
|
|
121
|
+
else if (ch === ']' || ch === 'P' || ch === 'X' || ch === '^' || ch === '_')
|
|
122
|
+
state = 'str';
|
|
123
|
+
// ⚠️ INTERMEDIATE BYTES (0x20-0x2F) DO NOT END THE SEQUENCE. `ESC ( B`
|
|
124
|
+
// — a charset designation — is three bytes, and treating `(` as the
|
|
125
|
+
// end marks the boundary before `B` as ground. Trimming there replays
|
|
126
|
+
// a bare `B` as ordinary text and silently drops the charset switch,
|
|
127
|
+
// which is precisely the "parser-safe boundary" this function promises
|
|
128
|
+
// not to do. Per ECMA-48, stay in escape until a FINAL byte
|
|
129
|
+
// (0x30-0x7E). (codex P2.)
|
|
130
|
+
else if (code >= 0x20 && code <= 0x2f) { /* intermediate — still escaping */ }
|
|
131
|
+
else
|
|
132
|
+
state = 'ground';
|
|
133
|
+
break;
|
|
134
|
+
case 'csi':
|
|
135
|
+
// Parameters and intermediates, terminated by a final byte 0x40-0x7E.
|
|
136
|
+
if (code >= 0x40 && code <= 0x7e)
|
|
137
|
+
state = 'ground';
|
|
138
|
+
break;
|
|
139
|
+
case 'str':
|
|
140
|
+
// BEL, or ST (ESC \). A newline does NOT end these — which is exactly
|
|
141
|
+
// what the previous newline shortcut got wrong.
|
|
142
|
+
if (code === 0x07)
|
|
143
|
+
state = 'ground';
|
|
144
|
+
else if (code === 0x1b && buf[i + 1] === '\\') {
|
|
145
|
+
state = 'ground';
|
|
146
|
+
i += 1;
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
i += 1;
|
|
151
|
+
}
|
|
152
|
+
return buf.length;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Drop the oldest bytes, cutting only at a resumable boundary.
|
|
156
|
+
*
|
|
157
|
+
* The cut only ever moves FORWARD, so it can never resurrect bytes that were
|
|
158
|
+
* already meant to be gone.
|
|
159
|
+
*/
|
|
160
|
+
export function trimBacklog(buf, limit = BACKLOG_CHARS) {
|
|
161
|
+
if (buf.length <= limit)
|
|
162
|
+
return buf;
|
|
163
|
+
return buf.slice(groundCutAt(buf, buf.length - limit));
|
|
164
|
+
}
|
|
165
|
+
function defaultSpawn() {
|
|
166
|
+
const pty = require('node-pty');
|
|
167
|
+
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 });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Constant-time secret comparison that cannot throw on a length mismatch.
|
|
171
|
+
*
|
|
172
|
+
* `timingSafeEqual` throws when the buffers differ in length, and a thrown
|
|
173
|
+
* comparison is both a crash and a length oracle. The length check short-
|
|
174
|
+
* circuits first, which leaks only the length — already visible from the URL.
|
|
175
|
+
*/
|
|
176
|
+
export function secretMatches(given, expected) {
|
|
177
|
+
if (typeof given !== 'string')
|
|
178
|
+
return false;
|
|
179
|
+
const a = Buffer.from(given);
|
|
180
|
+
const b = Buffer.from(expected);
|
|
181
|
+
if (a.length !== b.length)
|
|
182
|
+
return false;
|
|
183
|
+
return timingSafeEqual(a, b);
|
|
184
|
+
}
|
|
185
|
+
export async function startLocalShellServer(opts) {
|
|
186
|
+
const allowedOrigin = opts.allowedOrigin;
|
|
187
|
+
const spawnShell = opts.spawnShell ?? defaultSpawn();
|
|
188
|
+
const now = opts.now ?? Date.now;
|
|
189
|
+
const idleReapMs = opts.idleReapMs ?? IDLE_REAP_MS;
|
|
190
|
+
const maxSessions = opts.maxSessions ?? MAX_SESSIONS;
|
|
191
|
+
const secret = randomBytes(24).toString('hex');
|
|
192
|
+
const sessions = new Map();
|
|
193
|
+
const killSession = (s) => {
|
|
194
|
+
// ⚠️ RE-ENTRANT. `kill()` may fire `onExit` SYNCHRONOUSLY — the interface
|
|
195
|
+
// permits it and real PTYs do it — which calls straight back in here.
|
|
196
|
+
// Without this guard the second entry kills again, recursing until the
|
|
197
|
+
// stack blows. Checked BEFORE the flag is set, not after. (codex P2.)
|
|
198
|
+
if (s.exited)
|
|
199
|
+
return;
|
|
200
|
+
s.exited = true;
|
|
201
|
+
for (const v of s.viewers) {
|
|
202
|
+
try {
|
|
203
|
+
v.res.end();
|
|
204
|
+
}
|
|
205
|
+
catch { /* already gone */ }
|
|
206
|
+
}
|
|
207
|
+
s.viewers.clear();
|
|
208
|
+
try {
|
|
209
|
+
s.pty.kill();
|
|
210
|
+
}
|
|
211
|
+
catch { /* already dead */ }
|
|
212
|
+
sessions.delete(s.id);
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Send one chunk to one viewer, dropping rather than queueing when it is
|
|
216
|
+
* behind. See `Viewer` for why.
|
|
217
|
+
*
|
|
218
|
+
* The loss is REPORTED when the socket recovers — a viewer that silently
|
|
219
|
+
* skipped output would render a screen that is wrong with no indication,
|
|
220
|
+
* which is worse than a visible gap. Same discipline as the cloud path's
|
|
221
|
+
* `droppedBytes`.
|
|
222
|
+
*/
|
|
223
|
+
const writeToViewer = (session, v, data) => {
|
|
224
|
+
if (v.saturated) {
|
|
225
|
+
v.dropped += data.length;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
let ok = false;
|
|
229
|
+
try {
|
|
230
|
+
ok = v.res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (ok)
|
|
236
|
+
return;
|
|
237
|
+
v.saturated = true;
|
|
238
|
+
v.res.once('drain', () => {
|
|
239
|
+
v.saturated = false;
|
|
240
|
+
if (v.dropped > 0) {
|
|
241
|
+
const n = v.dropped;
|
|
242
|
+
v.dropped = 0;
|
|
243
|
+
// Marked inline, where the gap actually happened.
|
|
244
|
+
try {
|
|
245
|
+
v.res.write(`data: ${JSON.stringify(`\r\n\u001b[33m── ${n} bytes skipped — viewer fell behind ──\u001b[0m\r\n`)}\n\n`);
|
|
246
|
+
}
|
|
247
|
+
catch { /* gone */ }
|
|
248
|
+
}
|
|
249
|
+
// Re-seed from the retained tail so the screen is coherent again rather
|
|
250
|
+
// than resuming mid-stream after a hole.
|
|
251
|
+
try {
|
|
252
|
+
v.res.write(`data: ${JSON.stringify(session.backlog)}\n\n`);
|
|
253
|
+
}
|
|
254
|
+
catch { /* gone */ }
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
const reaper = setInterval(() => {
|
|
258
|
+
const t = now();
|
|
259
|
+
for (const s of [...sessions.values()]) {
|
|
260
|
+
// ⚠️ Only reap shells nobody is watching. A viewer that is merely quiet
|
|
261
|
+
// is still a viewer — killing on output-silence would take out an idle
|
|
262
|
+
// shell the operator is about to type into.
|
|
263
|
+
if (s.viewers.size === 0 && t - s.lastViewerAtMs > idleReapMs)
|
|
264
|
+
killSession(s);
|
|
265
|
+
}
|
|
266
|
+
}, Math.max(1000, Math.floor(idleReapMs / 4)));
|
|
267
|
+
reaper.unref?.();
|
|
268
|
+
/** CORS + Private Network Access. See this file's header for why all three. */
|
|
269
|
+
const applyCors = (req, res) => {
|
|
270
|
+
if (req.headers.origin === allowedOrigin) {
|
|
271
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
272
|
+
res.setHeader('Vary', 'Origin');
|
|
273
|
+
}
|
|
274
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
275
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
276
|
+
if (req.headers['access-control-request-private-network']) {
|
|
277
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const server = http.createServer((req, res) => {
|
|
281
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
282
|
+
applyCors(req, res);
|
|
283
|
+
if (req.method === 'OPTIONS') {
|
|
284
|
+
res.writeHead(204);
|
|
285
|
+
res.end();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Liveness probe. Deliberately UNAUTHENTICATED and content-free: it exists
|
|
289
|
+
// so a viewer can discover whether this machine is the one running the
|
|
290
|
+
// daemon before it has any reason to hold a secret. It reveals only that
|
|
291
|
+
// something is listening, which the TCP connect already revealed.
|
|
292
|
+
if (url.pathname === '/health') {
|
|
293
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify({ ok: true, service: 'yolo-bridge-local-shell' }));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!secretMatches(url.searchParams.get('secret'), secret)) {
|
|
298
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
299
|
+
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (url.pathname === '/open' && req.method === 'POST') {
|
|
303
|
+
if (sessions.size >= maxSessions) {
|
|
304
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
305
|
+
res.end(JSON.stringify({ error: `at most ${maxSessions} local terminals` }));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const cols = Math.max(20, Math.min(500, Number(url.searchParams.get('cols')) || 100));
|
|
309
|
+
const rows = Math.max(5, Math.min(200, Number(url.searchParams.get('rows')) || 30));
|
|
310
|
+
const id = randomBytes(9).toString('hex');
|
|
311
|
+
let pty;
|
|
312
|
+
try {
|
|
313
|
+
pty = spawnShell({ cols, rows });
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
// ⚠️ A THROW HERE WOULD KILL THE WHOLE DAEMON. This runs inside the
|
|
317
|
+
// HTTP request callback, so an unhandled exception takes the process
|
|
318
|
+
// down — and with it the agent PTY and every other live terminal —
|
|
319
|
+
// because one `$SHELL` pointed at a missing binary. Report it and
|
|
320
|
+
// leave everything else running. (codex P2.)
|
|
321
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
322
|
+
res.end(JSON.stringify({
|
|
323
|
+
error: 'could not start a shell',
|
|
324
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
325
|
+
}));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const session = { id, pty, backlog: '', viewers: new Set(), lastViewerAtMs: now(), exited: false };
|
|
329
|
+
// ⚠️ REGISTER FIRST, SUBSCRIBE SECOND. A short-lived shell can fire
|
|
330
|
+
// `onExit` the instant the callback is attached — before `sessions.set`
|
|
331
|
+
// would have run. `killSession` would then delete nothing, and the
|
|
332
|
+
// already-dead session would be inserted afterwards, unreachable (404 on
|
|
333
|
+
// every request) yet still holding a slot against the session cap, and
|
|
334
|
+
// unremovable because `killSession` returns early once `exited` is set.
|
|
335
|
+
// A slow leak of the one resource that is capped. (codex P1.)
|
|
336
|
+
sessions.set(id, session);
|
|
337
|
+
pty.onData((data) => {
|
|
338
|
+
// ⚠️ NEVER LOG THIS. It is the operator's live screen.
|
|
339
|
+
session.backlog = trimBacklog(session.backlog + data);
|
|
340
|
+
for (const viewer of session.viewers)
|
|
341
|
+
writeToViewer(session, viewer, data);
|
|
342
|
+
});
|
|
343
|
+
pty.onExit(() => { killSession(session); });
|
|
344
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
345
|
+
res.end(JSON.stringify({ sessionId: id, cols, rows }));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const session = sessions.get(url.searchParams.get('session') ?? '');
|
|
349
|
+
if (!session || session.exited) {
|
|
350
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
351
|
+
res.end(JSON.stringify({ error: 'no such session' }));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (url.pathname === '/stream') {
|
|
355
|
+
res.writeHead(200, {
|
|
356
|
+
'Content-Type': 'text/event-stream',
|
|
357
|
+
'Cache-Control': 'no-cache',
|
|
358
|
+
Connection: 'keep-alive',
|
|
359
|
+
});
|
|
360
|
+
// ⚠️ FLUSH THE HEADERS IMMEDIATELY. Node holds them until the first
|
|
361
|
+
// write, so a stream opened on a shell that has not printed anything yet
|
|
362
|
+
// never sends its response head at all — and the client's `fetch` hangs
|
|
363
|
+
// waiting for it, indefinitely. A fresh shell is exactly that case.
|
|
364
|
+
//
|
|
365
|
+
// An SSE comment is the standard way to do this: legal, ignored by
|
|
366
|
+
// EventSource, and it doubles as a "connected" signal the client can use
|
|
367
|
+
// to distinguish "attached and quiet" from "never got there".
|
|
368
|
+
res.write(': connected\n\n');
|
|
369
|
+
// Replay next, so a reconnecting viewer sees the screen rather than
|
|
370
|
+
// waiting for the next keypress to produce output.
|
|
371
|
+
if (session.backlog)
|
|
372
|
+
res.write(`data: ${JSON.stringify(session.backlog)}\n\n`);
|
|
373
|
+
const viewer = { res, saturated: false, dropped: 0 };
|
|
374
|
+
session.viewers.add(viewer);
|
|
375
|
+
session.lastViewerAtMs = now();
|
|
376
|
+
req.on('close', () => {
|
|
377
|
+
session.viewers.delete(viewer);
|
|
378
|
+
session.lastViewerAtMs = now();
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (url.pathname === '/input' && req.method === 'POST') {
|
|
383
|
+
// ⚠️ COLLECT BYTES, DECODE ONCE. `body += chunk` decodes each Buffer
|
|
384
|
+
// independently, so a multibyte character split across a TCP chunk
|
|
385
|
+
// boundary becomes two replacement characters — silently corrupting
|
|
386
|
+
// pasted or typed Unicode, depending on how the network happened to
|
|
387
|
+
// fragment it. (codex P2.)
|
|
388
|
+
const chunks = [];
|
|
389
|
+
let size = 0;
|
|
390
|
+
req.on('data', (c) => {
|
|
391
|
+
size += c.length;
|
|
392
|
+
// Bounded before parsing: an unbounded body is a memory DoS on a
|
|
393
|
+
// process that owns the operator's shells.
|
|
394
|
+
if (size > MAX_INPUT_CHARS * 4) {
|
|
395
|
+
req.destroy();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
chunks.push(c);
|
|
399
|
+
});
|
|
400
|
+
req.on('end', () => {
|
|
401
|
+
try {
|
|
402
|
+
const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'))?.data;
|
|
403
|
+
if (typeof data === 'string' && data.length <= MAX_INPUT_CHARS)
|
|
404
|
+
session.pty.write(data);
|
|
405
|
+
}
|
|
406
|
+
catch { /* malformed body is not worth an error page */ }
|
|
407
|
+
res.writeHead(204);
|
|
408
|
+
res.end();
|
|
409
|
+
});
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (url.pathname === '/resize' && req.method === 'POST') {
|
|
413
|
+
const cols = Number(url.searchParams.get('cols'));
|
|
414
|
+
const rows = Number(url.searchParams.get('rows'));
|
|
415
|
+
if (Number.isFinite(cols) && Number.isFinite(rows)) {
|
|
416
|
+
try {
|
|
417
|
+
session.pty.resize(Math.max(20, Math.min(500, cols)), Math.max(5, Math.min(200, rows)));
|
|
418
|
+
}
|
|
419
|
+
catch { /* raced exit */ }
|
|
420
|
+
}
|
|
421
|
+
res.writeHead(204);
|
|
422
|
+
res.end();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (url.pathname === '/close' && req.method === 'POST') {
|
|
426
|
+
killSession(session);
|
|
427
|
+
res.writeHead(204);
|
|
428
|
+
res.end();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
res.writeHead(404);
|
|
432
|
+
res.end();
|
|
433
|
+
});
|
|
434
|
+
await new Promise((resolve, reject) => {
|
|
435
|
+
server.once('error', reject);
|
|
436
|
+
// ⚠️ 127.0.0.1 EXPLICITLY. Omitting the host, or using '0.0.0.0', would put
|
|
437
|
+
// a shell on the local network.
|
|
438
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
439
|
+
});
|
|
440
|
+
const addr = server.address();
|
|
441
|
+
const port = addr.port;
|
|
442
|
+
return {
|
|
443
|
+
url: `http://127.0.0.1:${port}`,
|
|
444
|
+
port,
|
|
445
|
+
host: addr.address,
|
|
446
|
+
secret,
|
|
447
|
+
get sessionCount() { return sessions.size; },
|
|
448
|
+
async close() {
|
|
449
|
+
clearInterval(reaper);
|
|
450
|
+
for (const s of [...sessions.values()])
|
|
451
|
+
killSession(s);
|
|
452
|
+
await new Promise((resolve) => {
|
|
453
|
+
server.close(() => resolve());
|
|
454
|
+
// ⚠️ REQUIRED, not belt-and-braces. `server.close()` waits for open
|
|
455
|
+
// connections, and an SSE stream never ends on its own — that is the
|
|
456
|
+
// whole point of it. Without this, closing the daemon with a terminal
|
|
457
|
+
// open hangs forever instead of exiting.
|
|
458
|
+
server.closeAllConnections?.();
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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",
|