@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.
- package/dist/api-client.js +26 -0
- package/dist/attach-cmd.js +27 -0
- package/dist/cli.js +53 -0
- package/dist/local-shell-server.js +476 -0
- package/package.json +1 -1
package/dist/api-client.js
CHANGED
|
@@ -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);
|
package/dist/attach-cmd.js
CHANGED
|
@@ -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.
|
|
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",
|