@yolo-labs/yolobridge 0.28.0 → 0.30.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 +0 -14
- package/dist/attach-cmd.js +0 -27
- package/dist/cli.js +19 -53
- package/dist/resolve-agent-binary.js +105 -0
- package/package.json +1 -1
- package/dist/local-shell-server.js +0 -476
package/dist/api-client.js
CHANGED
|
@@ -199,20 +199,6 @@ export async function openStream(cfg, workspaceId, attachmentId) {
|
|
|
199
199
|
* report fails is still a perfectly good daemon; the tile simply falls back to
|
|
200
200
|
* the cloud relay, which is slower and works.
|
|
201
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
|
-
}
|
|
216
202
|
export async function postHeartbeat(cfg, workspaceId, attachmentId) {
|
|
217
203
|
const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
|
|
218
204
|
return Boolean(body?.recorded);
|
package/dist/attach-cmd.js
CHANGED
|
@@ -900,20 +900,6 @@ 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
|
-
}
|
|
917
903
|
}, (err) => {
|
|
918
904
|
// The heartbeat hits the SAME boundary as the stream open
|
|
919
905
|
// and gets the SAME 403, so it needs the same answer: a
|
|
@@ -926,19 +912,6 @@ export async function runAttachDaemon(deps) {
|
|
|
926
912
|
detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
|
|
927
913
|
});
|
|
928
914
|
}, 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
|
-
}
|
|
942
915
|
// Send one immediately so status isn't stale for the first ~10s.
|
|
943
916
|
apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => {
|
|
944
917
|
if (noteCredentialRejection(err))
|
package/dist/cli.js
CHANGED
|
@@ -35,24 +35,13 @@ import { runShare, runDeliver } from './share-cmd.js';
|
|
|
35
35
|
import { runAllow } from './approved-paths.js';
|
|
36
36
|
import { runDetach } from './detach-cmd.js';
|
|
37
37
|
import { getStatus, formatStatus } from './status-cmd.js';
|
|
38
|
+
import { resolveAgentBinary } from './resolve-agent-binary.js';
|
|
38
39
|
import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
|
|
39
40
|
import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
|
|
40
41
|
import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
|
|
41
|
-
import { startLocalShellServer } from './local-shell-server.js';
|
|
42
42
|
import { buildAgentMcpArgs } from './agent-mcp-args.js';
|
|
43
43
|
const DEFAULT_API_URL = 'https://api.yolo.studio';
|
|
44
|
-
const DEFAULT_WEBAPP_ORIGIN = 'https://yolo.studio';
|
|
45
44
|
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
|
-
}
|
|
56
45
|
function apiUrl() {
|
|
57
46
|
return process.env.YOLOBRIDGE_API_URL || DEFAULT_API_URL;
|
|
58
47
|
}
|
|
@@ -338,14 +327,6 @@ async function cmdAttach(args) {
|
|
|
338
327
|
cliVersion: readOwnVersion(),
|
|
339
328
|
});
|
|
340
329
|
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;
|
|
349
330
|
// argv fragment pointing the spawned agent at the local MCP proxy, or
|
|
350
331
|
// `[]` when MCP isn't wired in — see `agent-mcp-args.ts`. Nothing else is
|
|
351
332
|
// tracked for cleanup any more: as of 2026-08-26 `attach` writes NOTHING
|
|
@@ -356,12 +337,6 @@ async function cmdAttach(args) {
|
|
|
356
337
|
let result;
|
|
357
338
|
try {
|
|
358
339
|
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),
|
|
365
340
|
workspaceId,
|
|
366
341
|
commonApiBaseUrl: apiUrl(),
|
|
367
342
|
hostLabel: attachHostInfo.hostLabel,
|
|
@@ -388,22 +363,6 @@ async function cmdAttach(args) {
|
|
|
388
363
|
// prompt. MCP access is an enhancement on a tile that already works
|
|
389
364
|
// without it; the local agent spawning is not optional.
|
|
390
365
|
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
|
-
}
|
|
407
366
|
mcpProxyHandle = await startMcpProxy({
|
|
408
367
|
apiUrl: apiUrl(),
|
|
409
368
|
getAccessToken,
|
|
@@ -507,6 +466,24 @@ async function cmdAttach(args) {
|
|
|
507
466
|
// Say where Ctrl+C goes BEFORE the agent takes over the screen.
|
|
508
467
|
// Without this the operator presses it expecting to quit, nothing
|
|
509
468
|
// happens, and there is no way to discover why.
|
|
469
|
+
// ⚠️ RESOLVE THE BINARY BEFORE SPAWNING IT, so a failure can be
|
|
470
|
+
// EXPLAINED. node-pty reports an unspawnable binary as the bare
|
|
471
|
+
// string `posix_spawnp failed.` — no binary name, no PATH, no
|
|
472
|
+
// remedy — which on macOS reached an operator as "failed to start
|
|
473
|
+
// the local agent (posix_spawnp failed.)" and told them nothing
|
|
474
|
+
// they could act on. Worse, the one fact they could see (`claude`
|
|
475
|
+
// works when typed) argued that nothing was wrong, because a shell
|
|
476
|
+
// alias or function resolves interactively and cannot be spawned.
|
|
477
|
+
// This does not change WHETHER the attach fails — only whether the
|
|
478
|
+
// operator can tell why.
|
|
479
|
+
const resolvedBinary = resolveAgentBinary(agentBin ?? DEFAULT_AGENT_BIN, process.env);
|
|
480
|
+
if (!resolvedBinary.ok) {
|
|
481
|
+
process.stdout.write(`yolo-bridge: cannot start the agent — ${resolvedBinary.message}\n`);
|
|
482
|
+
process.stdout.write('yolo-bridge: detaching...\n');
|
|
483
|
+
stopRequested = true;
|
|
484
|
+
await runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
510
487
|
process.stdout.write('yolo-bridge: Ctrl+C goes to the agent · Ctrl-P Ctrl-Q to detach\n');
|
|
511
488
|
startLocalAgent({
|
|
512
489
|
agentBin,
|
|
@@ -579,17 +556,6 @@ async function cmdAttach(args) {
|
|
|
579
556
|
// or a reboot, and every skipped run left a file that broke the
|
|
580
557
|
// operator's own standalone `claude` in that directory. Nothing written
|
|
581
558
|
// 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
|
-
}
|
|
593
559
|
if (mcpProxyHandle) {
|
|
594
560
|
try {
|
|
595
561
|
await mcpProxyHandle.stop();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can the agent binary actually be spawned, and if not, WHY?
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE FAILURE THIS EXISTS FOR. node-pty reports an unspawnable binary as
|
|
5
|
+
* the literal string `posix_spawnp failed.` — no binary name, no PATH, no
|
|
6
|
+
* errno, no remedy. On macOS that surfaced as:
|
|
7
|
+
*
|
|
8
|
+
* yolo-bridge: failed to start the local agent (posix_spawnp failed.), detaching...
|
|
9
|
+
*
|
|
10
|
+
* which is true, useless, and indistinguishable from half a dozen unrelated
|
|
11
|
+
* causes. The operator cannot tell whether the agent is missing, installed but
|
|
12
|
+
* not executable, or whether `attach` even looked in the right place — and the
|
|
13
|
+
* one thing they CAN see (`claude` works when they type it) argues that
|
|
14
|
+
* nothing is wrong.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ THE MACOS TRAP THIS NAMES EXPLICITLY. `claude` working in the operator's
|
|
17
|
+
* terminal does NOT mean there is a binary to spawn: a shell alias or function
|
|
18
|
+
* from `.zshrc` resolves interactively and does not exist as a file, so
|
|
19
|
+
* `posix_spawnp` cannot find it however correct the PATH looks. A message that
|
|
20
|
+
* only says "not found" invites the reply "but it IS installed", so this says
|
|
21
|
+
* where it looked and offers the escape hatch.
|
|
22
|
+
*
|
|
23
|
+
* Resolution mirrors `posix_spawnp`: a name containing a separator is a path,
|
|
24
|
+
* anything else is searched across PATH in order.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
const DEFAULT_FS = fs;
|
|
29
|
+
function isExecutableFile(p, io) {
|
|
30
|
+
try {
|
|
31
|
+
if (!io.statSync(p).isFile())
|
|
32
|
+
return 'no';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 'no';
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
io.accessSync(p, io.constants.X_OK);
|
|
39
|
+
return 'yes';
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 'not-executable';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Where `bin` would be spawned from, or a sentence explaining why it cannot be.
|
|
47
|
+
*
|
|
48
|
+
* The message is the whole point: it is what the operator reads instead of
|
|
49
|
+
* `posix_spawnp failed.`, so it names the binary, says where the search
|
|
50
|
+
* looked, and gives the two ways out.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveAgentBinary(bin, env = process.env, io = DEFAULT_FS) {
|
|
53
|
+
const escapeHatch = `Point yolo-bridge at it explicitly with \`--agent /full/path/to/${bin}\` `
|
|
54
|
+
+ `(or set YOLOBRIDGE_AGENT_BIN).`;
|
|
55
|
+
// An explicit path is taken at its word — no PATH search, same as execve.
|
|
56
|
+
if (bin.includes(path.sep) || bin.includes('/')) {
|
|
57
|
+
const abs = path.resolve(bin);
|
|
58
|
+
const state = isExecutableFile(abs, io);
|
|
59
|
+
if (state === 'yes')
|
|
60
|
+
return { ok: true, path: abs };
|
|
61
|
+
if (state === 'not-executable') {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: 'not-executable',
|
|
65
|
+
message: `The agent at ${abs} exists but is not executable. \`chmod +x ${abs}\` and re-attach.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: false, reason: 'not-found', message: `No agent binary at ${abs}. ${escapeHatch}` };
|
|
69
|
+
}
|
|
70
|
+
const rawPath = env.PATH ?? '';
|
|
71
|
+
if (!rawPath.trim()) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: 'no-path',
|
|
75
|
+
message: `Cannot look for \`${bin}\`: PATH is empty in this process. ${escapeHatch}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const dirs = rawPath.split(path.delimiter).filter(Boolean);
|
|
79
|
+
let sawNonExecutable;
|
|
80
|
+
for (const dir of dirs) {
|
|
81
|
+
const candidate = path.join(dir, bin);
|
|
82
|
+
const state = isExecutableFile(candidate, io);
|
|
83
|
+
if (state === 'yes')
|
|
84
|
+
return { ok: true, path: candidate };
|
|
85
|
+
if (state === 'not-executable' && !sawNonExecutable)
|
|
86
|
+
sawNonExecutable = candidate;
|
|
87
|
+
}
|
|
88
|
+
if (sawNonExecutable) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: 'not-executable',
|
|
92
|
+
message: `Found \`${bin}\` at ${sawNonExecutable}, but it is not executable. `
|
|
93
|
+
+ `\`chmod +x ${sawNonExecutable}\` and re-attach.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: 'not-found',
|
|
99
|
+
message: `\`${bin}\` is not on this process's PATH (searched ${dirs.length} `
|
|
100
|
+
+ `${dirs.length === 1 ? 'directory' : 'directories'}).\n`
|
|
101
|
+
+ ` ⚠️ If \`${bin}\` works when you type it, it may be a shell alias or function rather than\n`
|
|
102
|
+
+ ` a real binary — those cannot be spawned. Check with: type -a ${bin}\n`
|
|
103
|
+
+ ` ${escapeHatch}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolo-labs/yolobridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|
|
@@ -1,476 +0,0 @@
|
|
|
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
|
-
}
|