agentradar-cli 0.1.2 → 0.1.3
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/README.md +4 -2
- package/dist/index.js +11 -1
- package/dist/runner.js +17 -0
- package/dist/setup.js +57 -2
- package/dist/vendor-runner/feeder.mjs +550 -0
- package/dist/vendor-runner/pane-live.sh +27 -0
- package/dist/vendor-runner/session-frame.mjs +119 -0
- package/dist/vendor-runner/supervisor/supervise-agent.sh +362 -0
- package/dist/vendor-runner/terminal-bridge.mjs +467 -0
- package/package.json +4 -2
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// AgentRadar #133 (M4 live terminal) — the mini-side FEEDER BRIDGE.
|
|
2
|
+
//
|
|
3
|
+
// Opens a persistent OUTBOUND WebSocket to the AgentRadar server (the mini INITIATES; the server never
|
|
4
|
+
// connects inbound — phone-home preserved) and bridges the agent's tmux pane:
|
|
5
|
+
// - server {attach} → start `tmux pipe-pane` → stream pane output as {output} frames (+ an initial
|
|
6
|
+
// capture-pane so a late viewer sees the current screen).
|
|
7
|
+
// - server {detach} → stop piping (idle agents cost nothing).
|
|
8
|
+
// - pane GEOMETRY → {hello cols,rows} on connect + {geometry cols,rows} on change while attached (#9).
|
|
9
|
+
// The server has no other way to know the pane size, and without it the client
|
|
10
|
+
// watch-terminal renders every agent at a hardcoded 120×30.
|
|
11
|
+
// - server {input} → `tmux send-keys -l` (VENDOR keystrokes; the server already dropped client input).
|
|
12
|
+
// - server {resize} → `tmux resize-window -x -y`.
|
|
13
|
+
// Auth: `Sec-WebSocket-Protocol: agentradar-feeder, <AWP bearer>` (never a ?token= query; never logged).
|
|
14
|
+
//
|
|
15
|
+
// DEPENDENCY-FREE (node builtins only), matching feeder.mjs. Minimal RFC6455 client framing (mask on
|
|
16
|
+
// send; parse unmasked server frames). Reconnects with backoff. Launched by the supervisor alongside
|
|
17
|
+
// feeder.mjs; it NEVER touches the liveness poll loop.
|
|
18
|
+
//
|
|
19
|
+
// SECRET HYGIENE (disc §8/§7b): the bearer + creds are file-staged 0600 and never printed to the pane
|
|
20
|
+
// (#152/#166), so the streamed pane can't leak them. VENDOR NOTE (disc §7b): the client RO view shows the
|
|
21
|
+
// vendor's RAW session — a vendor must NOT print their OWN non-AgentRadar secrets into the pane (the
|
|
22
|
+
// server-side scrubber only masks AgentRadar cred shapes ar_/sk-ant-/Bearer/AKIA).
|
|
23
|
+
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import https from "node:https";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
const AWP_MCP_ENDPOINT = process.env.AWP_MCP_URL ?? "https://staging.agentradar.com/api/mcp";
|
|
33
|
+
const AWP_BEARER = (() => {
|
|
34
|
+
const f = process.env.AWP_BEARER_FILE;
|
|
35
|
+
if (f) {
|
|
36
|
+
try {
|
|
37
|
+
return fs.readFileSync(f, "utf8").trim();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(`[bridge] FATAL: AWP_BEARER_FILE unreadable (${f}): ${e.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return process.env.AWP_BEARER ?? "";
|
|
44
|
+
})();
|
|
45
|
+
const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
|
|
46
|
+
// Resolve the tmux binary robustly: a bare "tmux" only works if it's on PATH, but the bridge is often
|
|
47
|
+
// launched from a supervisor / LaunchAgent whose PATH excludes e.g. /opt/homebrew/bin — then every tmux
|
|
48
|
+
// call ENOENTs and pipe-pane/send-keys silently no-op (feeder connects but streams nothing). Prefer an
|
|
49
|
+
// explicit TMUX_BIN; else probe common absolute locations; else fall back to bare "tmux".
|
|
50
|
+
const TMUX_BIN = (() => {
|
|
51
|
+
if (process.env.TMUX_BIN) return process.env.TMUX_BIN;
|
|
52
|
+
for (const p of ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]) {
|
|
53
|
+
try {
|
|
54
|
+
if (fs.existsSync(p)) return p;
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return "tmux";
|
|
60
|
+
})();
|
|
61
|
+
const RECONNECT_MIN_MS = Number(process.env.BRIDGE_RECONNECT_MIN_MS ?? "1000");
|
|
62
|
+
const RECONNECT_MAX_MS = Number(process.env.BRIDGE_RECONNECT_MAX_MS ?? "15000");
|
|
63
|
+
// #18: the server heartbeat-pings every 30s (#198). If pings STOP the connection is dead even though the OS
|
|
64
|
+
// still reports it ESTABLISHED — an ALB holds the client leg open for the whole target `deregistration_delay`
|
|
65
|
+
// (300s on staging) after the backend task is gone, and nothing on this side notices. Measured: across a
|
|
66
|
+
// staging rollout the bridge sat on a dead socket for ~5 minutes and only reconnected when the ALB finally
|
|
67
|
+
// force-closed it — i.e. a ~5 min live-terminal blackout on EVERY deploy. Watch for ping silence ourselves so
|
|
68
|
+
// recovery is ours, not the load balancer's.
|
|
69
|
+
const SERVER_PING_TIMEOUT_MS = Number(process.env.BRIDGE_PING_TIMEOUT_MS ?? "90000"); // 3× the 30s server ping
|
|
70
|
+
// #9: how often the pane size is re-read WHILE A VIEWER IS ATTACHED. tmux gives us no resize event, so the
|
|
71
|
+
// only way to notice a pane change is to ask. The poll runs only between attach and detach, so an unwatched
|
|
72
|
+
// agent still costs nothing, and a frame is sent only when the size actually CHANGED — an unchanged read is
|
|
73
|
+
// one cheap tmux call and no traffic.
|
|
74
|
+
const GEOMETRY_POLL_MS = Number(process.env.BRIDGE_GEOMETRY_POLL_MS ?? "5000");
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* #18: run `fn` at most once, however many times the returned function is called.
|
|
78
|
+
* A socket emits `error` AND THEN `close`, so a teardown registered on both used to run twice and schedule
|
|
79
|
+
* TWO reconnects — leaving one bridge process holding two independent feeder sockets (observed live).
|
|
80
|
+
* Exported so the once-only guarantee is unit-tested rather than assumed.
|
|
81
|
+
*/
|
|
82
|
+
export function once(fn) {
|
|
83
|
+
let called = false;
|
|
84
|
+
return (...args) => {
|
|
85
|
+
if (called) return undefined;
|
|
86
|
+
called = true;
|
|
87
|
+
return fn(...args);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Derive the feeder WS URL from the MCP endpoint: https://host/api/mcp → wss://host/internal/agent-terminal/feeder
|
|
92
|
+
function feederWsUrl() {
|
|
93
|
+
const u = new URL(AWP_MCP_ENDPOINT);
|
|
94
|
+
u.protocol = u.protocol === "http:" ? "ws:" : "wss:";
|
|
95
|
+
u.pathname = "/internal/agent-terminal/feeder";
|
|
96
|
+
u.search = "";
|
|
97
|
+
return u;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- minimal RFC6455 frame codec (exported for unit test) ----
|
|
101
|
+
export function encodeFrame(payload, opcode = 0x1) {
|
|
102
|
+
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), "utf8");
|
|
103
|
+
const len = data.length;
|
|
104
|
+
let header;
|
|
105
|
+
if (len < 126) header = Buffer.alloc(2 + 4);
|
|
106
|
+
else if (len < 65536) header = Buffer.alloc(4 + 4);
|
|
107
|
+
else header = Buffer.alloc(10 + 4);
|
|
108
|
+
header[0] = 0x80 | (opcode & 0x0f); // FIN + opcode
|
|
109
|
+
let maskOffset;
|
|
110
|
+
if (len < 126) {
|
|
111
|
+
header[1] = 0x80 | len; // MASK + len
|
|
112
|
+
maskOffset = 2;
|
|
113
|
+
} else if (len < 65536) {
|
|
114
|
+
header[1] = 0x80 | 126;
|
|
115
|
+
header.writeUInt16BE(len, 2);
|
|
116
|
+
maskOffset = 4;
|
|
117
|
+
} else {
|
|
118
|
+
header[1] = 0x80 | 127;
|
|
119
|
+
header.writeUInt32BE(0, 2);
|
|
120
|
+
header.writeUInt32BE(len, 6);
|
|
121
|
+
maskOffset = 10;
|
|
122
|
+
}
|
|
123
|
+
const mask = randomBytes(4);
|
|
124
|
+
mask.copy(header, maskOffset);
|
|
125
|
+
const masked = Buffer.alloc(len);
|
|
126
|
+
for (let i = 0; i < len; i++) masked[i] = data[i] ^ mask[i & 3];
|
|
127
|
+
return Buffer.concat([header, masked]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Parse as many complete (unmasked, server→client) frames as are buffered. Returns {frames, rest}.
|
|
131
|
+
export function parseFrames(buf) {
|
|
132
|
+
const frames = [];
|
|
133
|
+
let off = 0;
|
|
134
|
+
while (off + 2 <= buf.length) {
|
|
135
|
+
const b0 = buf[off];
|
|
136
|
+
const b1 = buf[off + 1];
|
|
137
|
+
const opcode = b0 & 0x0f;
|
|
138
|
+
const masked = (b1 & 0x80) !== 0;
|
|
139
|
+
let len = b1 & 0x7f;
|
|
140
|
+
let p = off + 2;
|
|
141
|
+
if (len === 126) {
|
|
142
|
+
if (p + 2 > buf.length) break;
|
|
143
|
+
len = buf.readUInt16BE(p);
|
|
144
|
+
p += 2;
|
|
145
|
+
} else if (len === 127) {
|
|
146
|
+
if (p + 8 > buf.length) break;
|
|
147
|
+
len = Number(buf.readBigUInt64BE(p));
|
|
148
|
+
p += 8;
|
|
149
|
+
}
|
|
150
|
+
const maskKey = masked ? buf.subarray(p, p + 4) : null;
|
|
151
|
+
if (masked) p += 4;
|
|
152
|
+
if (p + len > buf.length) break; // incomplete
|
|
153
|
+
let payload = buf.subarray(p, p + len);
|
|
154
|
+
if (masked && maskKey) {
|
|
155
|
+
const out = Buffer.alloc(len);
|
|
156
|
+
for (let i = 0; i < len; i++) out[i] = payload[i] ^ maskKey[i & 3];
|
|
157
|
+
payload = out;
|
|
158
|
+
}
|
|
159
|
+
frames.push({ opcode, payload });
|
|
160
|
+
off = p + len;
|
|
161
|
+
}
|
|
162
|
+
return { frames, rest: buf.subarray(off) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let tmuxSpawnWarned = false;
|
|
166
|
+
function tmux(argv) {
|
|
167
|
+
const r = spawnSync(TMUX_BIN, argv, { encoding: "utf8" });
|
|
168
|
+
// spawnSync does NOT throw on ENOENT — it returns { error }. Surface it ONCE so a missing/mis-pathed
|
|
169
|
+
// tmux is visible instead of silently no-op'ing (feeder up, zero output) as it otherwise would.
|
|
170
|
+
if (r.error && !tmuxSpawnWarned) {
|
|
171
|
+
tmuxSpawnWarned = true;
|
|
172
|
+
console.error(`[bridge] FATAL: cannot exec tmux ("${TMUX_BIN}"): ${r.error.message}. Set TMUX_BIN to its absolute path.`);
|
|
173
|
+
}
|
|
174
|
+
return r;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* #9: the CURRENT pane geometry, or null if tmux could not answer.
|
|
179
|
+
*
|
|
180
|
+
* The server has no other way to know this — it only ever sees `output` bytes — so before this existed the
|
|
181
|
+
* client watch-terminal rendered every agent at a hardcoded 120×30, which is wrong for all three fleet panes
|
|
182
|
+
* (80×24, 80×24, 104×36). Returning null on any doubt is deliberate: the server ignores a malformed report
|
|
183
|
+
* and keeps the last known good size, which is strictly better than publishing a guess.
|
|
184
|
+
*/
|
|
185
|
+
function readPaneGeometry() {
|
|
186
|
+
const r = tmux(["display-message", "-p", "-t", TMUX_SESSION, "#{pane_width}x#{pane_height}"]);
|
|
187
|
+
if (r.status !== 0) return null;
|
|
188
|
+
return parsePaneGeometry(r.stdout);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* #9: the pure half of readPaneGeometry — parse+validate `tmux display-message` output. Separated so the
|
|
193
|
+
* validation is unit-testable without a tmux; the invocation half is exercised by the live gate.
|
|
194
|
+
*
|
|
195
|
+
* Everything that is not exactly `<digits>x<digits>` returns null, including tmux's own error text. That
|
|
196
|
+
* matters because tmux writes "can't find session" to STDOUT with a zero-ish status in some versions, so a
|
|
197
|
+
* status check alone would let a string through.
|
|
198
|
+
*/
|
|
199
|
+
export function parsePaneGeometry(stdout) {
|
|
200
|
+
if (typeof stdout !== "string") return null;
|
|
201
|
+
const m = stdout.trim().match(/^(\d+)x(\d+)$/);
|
|
202
|
+
if (!m) return null;
|
|
203
|
+
const cols = Number(m[1]);
|
|
204
|
+
const rows = Number(m[2]);
|
|
205
|
+
if (!cols || !rows) return null;
|
|
206
|
+
return { cols, rows };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
class TerminalBridge {
|
|
210
|
+
constructor() {
|
|
211
|
+
this.sock = null;
|
|
212
|
+
this.piping = false;
|
|
213
|
+
this.fifoPath = null;
|
|
214
|
+
this.fifoStream = null;
|
|
215
|
+
this.recvBuf = Buffer.alloc(0);
|
|
216
|
+
this.backoff = RECONNECT_MIN_MS;
|
|
217
|
+
this.pingTimer = null;
|
|
218
|
+
// #9: last geometry we told the SERVER about, so we only send a frame when it actually changed.
|
|
219
|
+
// Reset to null on every (re)connect — a reconnect may land on a different server task with no memory
|
|
220
|
+
// of this session, so the new peer must be told even if the pane never moved.
|
|
221
|
+
this.geometry = null;
|
|
222
|
+
this.geometryTimer = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* #18: (re)arm the dead-peer watchdog. Called on connect and on EVERY inbound byte.
|
|
227
|
+
*
|
|
228
|
+
* Deliberately grounded in observed traffic rather than in ping frames specifically. Re-arming only on
|
|
229
|
+
* pings (0x9) would make this timer's safety depend on a constant in ANOTHER process: #198 currently
|
|
230
|
+
* pings every connection unconditionally every 30s, but its stated motivation is keeping *idle* sockets
|
|
231
|
+
* off the ALB idle timeout — so if it ever becomes idle-only, a healthy, actively-streaming bridge would
|
|
232
|
+
* stop receiving pings and this watchdog would kill it every SERVER_PING_TIMEOUT_MS. Any inbound byte
|
|
233
|
+
* proves the peer is alive, so there is no cross-process invariant left to break.
|
|
234
|
+
*/
|
|
235
|
+
armPingWatchdog(socket) {
|
|
236
|
+
this.clearPingWatchdog();
|
|
237
|
+
if (!SERVER_PING_TIMEOUT_MS) return; // 0 disables
|
|
238
|
+
this.pingTimer = setTimeout(() => {
|
|
239
|
+
console.log(`[bridge] no server ping in ${SERVER_PING_TIMEOUT_MS}ms — treating peer as dead, reconnecting`);
|
|
240
|
+
try {
|
|
241
|
+
socket.destroy();
|
|
242
|
+
} catch {
|
|
243
|
+
/* the close/error handler drives the reconnect */
|
|
244
|
+
}
|
|
245
|
+
}, SERVER_PING_TIMEOUT_MS);
|
|
246
|
+
if (typeof this.pingTimer.unref === "function") this.pingTimer.unref();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
clearPingWatchdog() {
|
|
250
|
+
if (this.pingTimer) {
|
|
251
|
+
clearTimeout(this.pingTimer);
|
|
252
|
+
this.pingTimer = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
send(obj) {
|
|
257
|
+
if (!this.sock || this.sock.destroyed) return;
|
|
258
|
+
try {
|
|
259
|
+
this.sock.write(encodeFrame(JSON.stringify(obj), 0x1));
|
|
260
|
+
} catch {
|
|
261
|
+
/* dead socket; reconnect loop handles it */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* #9: tell the server the pane size, but only when it CHANGED (or when `force`, e.g. right after a viewer
|
|
267
|
+
* attached or after we reconnected). Sending unconditionally would re-broadcast a `ready` frame to every
|
|
268
|
+
* open browser on every poll tick.
|
|
269
|
+
*/
|
|
270
|
+
reportGeometry(force = false) {
|
|
271
|
+
const g = readPaneGeometry();
|
|
272
|
+
if (!g) return;
|
|
273
|
+
if (!force && this.geometry && this.geometry.cols === g.cols && this.geometry.rows === g.rows) return;
|
|
274
|
+
this.geometry = g;
|
|
275
|
+
this.send({ type: "geometry", cols: g.cols, rows: g.rows });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
startGeometryPoll() {
|
|
279
|
+
if (this.geometryTimer) return;
|
|
280
|
+
this.geometryTimer = setInterval(() => this.reportGeometry(), GEOMETRY_POLL_MS);
|
|
281
|
+
if (typeof this.geometryTimer.unref === "function") this.geometryTimer.unref();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
stopGeometryPoll() {
|
|
285
|
+
if (!this.geometryTimer) return;
|
|
286
|
+
clearInterval(this.geometryTimer);
|
|
287
|
+
this.geometryTimer = null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
startPipe() {
|
|
291
|
+
if (this.piping) return;
|
|
292
|
+
this.piping = true;
|
|
293
|
+
// #9: a viewer just attached — publish the real pane size before the first bytes, so the client sizes
|
|
294
|
+
// its grid correctly rather than painting the replay into a fallback grid and re-flowing a moment later.
|
|
295
|
+
this.reportGeometry(true);
|
|
296
|
+
this.startGeometryPoll();
|
|
297
|
+
// Initial screen so a viewer attaching mid-session sees current state.
|
|
298
|
+
const cap = tmux(["capture-pane", "-ep", "-t", TMUX_SESSION]);
|
|
299
|
+
if (cap.status === 0 && cap.stdout) this.send({ type: "output", data: cap.stdout });
|
|
300
|
+
// Stream new output via a fifo that tmux pipe-pane writes into.
|
|
301
|
+
this.fifoPath = path.join(os.tmpdir(), `ar-bridge-${process.pid}-${Date.now()}.fifo`);
|
|
302
|
+
spawnSync("mkfifo", [this.fifoPath]);
|
|
303
|
+
tmux(["pipe-pane", "-t", TMUX_SESSION, `cat >> ${this.fifoPath}`]);
|
|
304
|
+
this.fifoStream = fs.createReadStream(this.fifoPath);
|
|
305
|
+
this.fifoStream.on("data", (chunk) => this.send({ type: "output", data: chunk.toString("utf8") }));
|
|
306
|
+
this.fifoStream.on("error", () => {});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
stopPipe() {
|
|
310
|
+
if (!this.piping) return;
|
|
311
|
+
this.piping = false;
|
|
312
|
+
this.stopGeometryPoll(); // #9: nobody is watching → stop asking tmux anything
|
|
313
|
+
tmux(["pipe-pane", "-t", TMUX_SESSION]); // empty command → stop piping
|
|
314
|
+
if (this.fifoStream) {
|
|
315
|
+
try {
|
|
316
|
+
this.fifoStream.destroy();
|
|
317
|
+
} catch {
|
|
318
|
+
/* ignore */
|
|
319
|
+
}
|
|
320
|
+
this.fifoStream = null;
|
|
321
|
+
}
|
|
322
|
+
if (this.fifoPath) {
|
|
323
|
+
try {
|
|
324
|
+
fs.unlinkSync(this.fifoPath);
|
|
325
|
+
} catch {
|
|
326
|
+
/* ignore */
|
|
327
|
+
}
|
|
328
|
+
this.fifoPath = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
onServerFrame(obj) {
|
|
333
|
+
switch (obj?.type) {
|
|
334
|
+
case "attach":
|
|
335
|
+
this.startPipe();
|
|
336
|
+
break;
|
|
337
|
+
case "detach":
|
|
338
|
+
this.stopPipe();
|
|
339
|
+
break;
|
|
340
|
+
case "input":
|
|
341
|
+
if (typeof obj.data === "string") tmux(["send-keys", "-t", TMUX_SESSION, "-l", obj.data]);
|
|
342
|
+
break;
|
|
343
|
+
case "resize":
|
|
344
|
+
if (Number.isFinite(obj.cols) && Number.isFinite(obj.rows)) {
|
|
345
|
+
tmux(["resize-window", "-t", TMUX_SESSION, "-x", String(obj.cols), "-y", String(obj.rows)]);
|
|
346
|
+
// #9: report what tmux ACTUALLY did, not what was asked for. tmux clamps to the smallest attached
|
|
347
|
+
// client, so the requested size and the resulting pane routinely differ — echoing the request back
|
|
348
|
+
// would publish a size the pane does not have.
|
|
349
|
+
this.reportGeometry();
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
default:
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
connect() {
|
|
358
|
+
const url = feederWsUrl();
|
|
359
|
+
const key = randomBytes(16).toString("base64");
|
|
360
|
+
const lib = url.protocol === "wss:" ? https : http;
|
|
361
|
+
const req = lib.request({
|
|
362
|
+
hostname: url.hostname,
|
|
363
|
+
port: url.port || (url.protocol === "wss:" ? 443 : 80),
|
|
364
|
+
path: url.pathname,
|
|
365
|
+
method: "GET",
|
|
366
|
+
headers: {
|
|
367
|
+
Connection: "Upgrade",
|
|
368
|
+
Upgrade: "websocket",
|
|
369
|
+
"Sec-WebSocket-Version": "13",
|
|
370
|
+
"Sec-WebSocket-Key": key,
|
|
371
|
+
// Auth rides the subprotocol (never a query param); NEVER logged.
|
|
372
|
+
"Sec-WebSocket-Protocol": `agentradar-feeder, ${AWP_BEARER}`,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
req.on("upgrade", (res, socket) => {
|
|
376
|
+
const accept = createHash("sha1")
|
|
377
|
+
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
|
|
378
|
+
.digest("base64");
|
|
379
|
+
if (res.headers["sec-websocket-accept"] !== accept) {
|
|
380
|
+
socket.destroy();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.sock = socket;
|
|
384
|
+
this.backoff = RECONNECT_MIN_MS;
|
|
385
|
+
console.log("[bridge] connected");
|
|
386
|
+
this.armPingWatchdog(socket); // #18
|
|
387
|
+
// #9: a reconnect may land on a different server task, which has no memory of this session — so the
|
|
388
|
+
// new peer is told the pane size unconditionally, in the very first frame.
|
|
389
|
+
this.geometry = readPaneGeometry();
|
|
390
|
+
this.send({
|
|
391
|
+
type: "hello",
|
|
392
|
+
session: TMUX_SESSION,
|
|
393
|
+
...(this.geometry ? { cols: this.geometry.cols, rows: this.geometry.rows } : {}),
|
|
394
|
+
});
|
|
395
|
+
socket.on("data", (buf) => {
|
|
396
|
+
this.armPingWatchdog(socket); // #18: any inbound byte proves the peer is alive — see armPingWatchdog
|
|
397
|
+
this.recvBuf = Buffer.concat([this.recvBuf, buf]);
|
|
398
|
+
const { frames, rest } = parseFrames(this.recvBuf);
|
|
399
|
+
this.recvBuf = rest;
|
|
400
|
+
for (const f of frames) {
|
|
401
|
+
if (f.opcode === 0x8) {
|
|
402
|
+
socket.end();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (f.opcode === 0x9) {
|
|
406
|
+
// #198: the server heartbeat-pings idle connections (< ALB idle timeout) so the feeder WS
|
|
407
|
+
// doesn't go half-open. Reply pong (0xA, echo payload) — else the server terminates us as dead.
|
|
408
|
+
// (The dead-peer watchdog is re-armed by the `data` handler above, for ANY frame — this branch
|
|
409
|
+
// deliberately does NOT re-arm, so nothing here couples to the server's ping cadence.)
|
|
410
|
+
try {
|
|
411
|
+
socket.write(encodeFrame(f.payload, 0xA));
|
|
412
|
+
} catch {
|
|
413
|
+
/* dead socket; reconnect loop handles it */
|
|
414
|
+
}
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (f.opcode === 0x1) {
|
|
418
|
+
try {
|
|
419
|
+
this.onServerFrame(JSON.parse(f.payload.toString("utf8")));
|
|
420
|
+
} catch {
|
|
421
|
+
/* ignore malformed */
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
// #18: `error` is followed by `close` on the same socket, so registering this on BOTH fired it TWICE
|
|
427
|
+
// and scheduled TWO reconnects — leaving ONE bridge process holding TWO independent feeder sockets.
|
|
428
|
+
// Observed in production: the Spielberg bridge held two ESTABLISHED sockets to two different ALB IPs.
|
|
429
|
+
// That is what makes the server-side stale-close bug reachable (a superseded socket closing used to
|
|
430
|
+
// unbind the live feeder). Fire teardown exactly once per socket.
|
|
431
|
+
const teardown = once(() => {
|
|
432
|
+
this.clearPingWatchdog();
|
|
433
|
+
this.stopPipe();
|
|
434
|
+
this.sock = null;
|
|
435
|
+
this.recvBuf = Buffer.alloc(0);
|
|
436
|
+
this.scheduleReconnect();
|
|
437
|
+
});
|
|
438
|
+
socket.on("close", teardown);
|
|
439
|
+
socket.on("error", teardown);
|
|
440
|
+
});
|
|
441
|
+
// A non-upgrade HTTP reply (e.g. 502 during a deploy window, or 200 SPA-fallback if the WS
|
|
442
|
+
// route isn't wired) fires "response", NOT "upgrade"/"error". Without this handler the bridge
|
|
443
|
+
// would hang silently (no reconnect). Log the status and back off → reconnect.
|
|
444
|
+
req.on("response", (res) => {
|
|
445
|
+
console.log(`[bridge] upgrade refused: HTTP ${res.statusCode} (expected 101) — will retry`);
|
|
446
|
+
res.resume(); // drain so the socket can free
|
|
447
|
+
this.scheduleReconnect();
|
|
448
|
+
});
|
|
449
|
+
req.on("error", () => this.scheduleReconnect());
|
|
450
|
+
req.end();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
scheduleReconnect() {
|
|
454
|
+
const d = this.backoff;
|
|
455
|
+
this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
|
|
456
|
+
setTimeout(() => this.connect(), d);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Only auto-run when executed directly (not when imported by the unit test).
|
|
461
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
462
|
+
if (!AWP_BEARER) {
|
|
463
|
+
console.error("[bridge] FATAL: no AWP bearer (set AWP_BEARER_FILE or AWP_BEARER)");
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
new TerminalBridge().connect();
|
|
467
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentradar-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Secure AgentRadar vendor setup and skill installer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dist/index.js",
|
|
11
11
|
"dist/setup.js",
|
|
12
|
+
"dist/runner.js",
|
|
12
13
|
"dist/skill-install.js",
|
|
14
|
+
"dist/vendor-runner/**",
|
|
13
15
|
"dist/agentradar-vendor/SKILL.md",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
"access": "public"
|
|
19
21
|
},
|
|
20
22
|
"scripts": {
|
|
21
|
-
"build": "tsc && mkdir -p dist/agentradar-vendor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && chmod +x dist/index.js",
|
|
23
|
+
"build": "tsc && mkdir -p dist/agentradar-vendor dist/vendor-runner/supervisor && cp ../../skills/agentradar-vendor/SKILL.md dist/agentradar-vendor/SKILL.md && cp ../../templates/vendor-runner/feeder.mjs ../../templates/vendor-runner/pane-live.sh ../../templates/vendor-runner/session-frame.mjs ../../templates/vendor-runner/terminal-bridge.mjs dist/vendor-runner/ && cp ../../templates/vendor-runner/supervisor/supervise-agent.sh dist/vendor-runner/supervisor/ && chmod +x dist/index.js dist/vendor-runner/pane-live.sh dist/vendor-runner/supervisor/supervise-agent.sh",
|
|
22
24
|
"clean": "rm -rf dist",
|
|
23
25
|
"prepack": "pnpm run build",
|
|
24
26
|
"typecheck": "tsc --noEmit",
|