@songsid/agend 2.1.4 → 2.1.5-beta.1

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.
@@ -0,0 +1,833 @@
1
+ /**
2
+ * Web terminal session: one command, one tmux pane, one browser, a few minutes.
3
+ *
4
+ * This is the core of remote `/login` and `/install-cli` (v2.1.5). The fleet
5
+ * starts exactly one command inside a *dedicated* tmux server and hands the
6
+ * admin a browser terminal onto that single pane. Nothing here interprets the
7
+ * CLI's screens or presses keys on the user's behalf — the human is the TUI's
8
+ * interpreter, so every CLI's every login flow works without per-CLI modelling.
9
+ *
10
+ * Security shape (design doc §3):
11
+ * - scope: the browser never gets a tmux client. Input goes through
12
+ * `send-keys -H` to this one pane; the pane runs `sh -c "<command>"` and
13
+ * dies when the command exits. No shell is reachable.
14
+ * - token gate: the URL carries no secret (sid is a path, not a credential).
15
+ * A separate one-time access token — delivered over the authenticated chat
16
+ * channel — is verified in constant time; three failures destroy the
17
+ * session. Success yields a session-bound cookie.
18
+ * - TTL: the session ends when the process exits, when the TTL lapses, on
19
+ * lockout, or on cancel. Ending always kills the tmux server.
20
+ * - observation: the fleet reads the pane (capture-pane) to post the device
21
+ * URL/code into chat and to judge success/failure — read-only.
22
+ *
23
+ * Output streaming uses `pipe-pane` into a FIFO we hold open O_RDWR (never
24
+ * EOF, nothing on disk). The pane is created running a placeholder and the
25
+ * real command is started with `respawn-pane -k` *after* the pipe is attached,
26
+ * so the very first bytes are captured (verified live: new-session + pipe-pane
27
+ * loses the first line; placeholder + respawn does not).
28
+ */
29
+ import { EventEmitter } from "node:events";
30
+ import { execFile } from "node:child_process";
31
+ import { randomBytes, timingSafeEqual } from "node:crypto";
32
+ import { mkdtempSync, openSync, rmSync, readFileSync, constants as fsConstants } from "node:fs";
33
+ import { Socket as NetSocket } from "node:net";
34
+ import { tmpdir } from "node:os";
35
+ import { join } from "node:path";
36
+ // ── Constants ────────────────────────────────────────────────────────────────
37
+ export const ACCESS_TOKEN_LENGTH = 20;
38
+ export const MAX_TOKEN_ATTEMPTS = 3;
39
+ export const MAX_TTL_MS = 20 * 60_000;
40
+ export const DEFAULT_COLS = 120;
41
+ export const DEFAULT_ROWS = 36;
42
+ export const MIN_COLS = 20, MAX_COLS = 250, MIN_ROWS = 5, MAX_ROWS = 100;
43
+ const REPLAY_BUFFER_LIMIT = 256 * 1024;
44
+ const POLL_INTERVAL_MS = 1_000;
45
+ const SUCCESS_EXIT_GRACE_MS = 15_000;
46
+ /** Bytes of browser input allowed to wait for tmux before the session is ended as wedged (B4). */
47
+ export const MAX_PENDING_INPUT_BYTES = 64 * 1024;
48
+ /** Queued tmux operations allowed to wait (input batches + at most one resize); more means tmux is stuck. */
49
+ export const MAX_PENDING_JOBS = 64;
50
+ /** Upper bound on any single tmux invocation (B5: a wedged tmux must not hang the session forever). */
51
+ const TMUX_EXEC_TIMEOUT_MS = 10_000;
52
+ const KILL_TIMEOUT_MS = 5_000;
53
+ /** Consecutive failed pane probes before the session is ended as unreachable. */
54
+ export const MAX_PROBE_FAILURES = 3;
55
+ const GENERIC_URL = /https:\/\/[^\s"'<>\])]+/;
56
+ /** RFC 4648 base32 alphabet without padding — unambiguous when typed on a phone. */
57
+ const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
58
+ /** 20 base32 chars = 100 bits of entropy (13 random bytes, the last 4 bits truncated). */
59
+ export function generateAccessToken(bytes = randomBytes(13)) {
60
+ let bits = 0, value = 0, out = "";
61
+ for (const byte of bytes) {
62
+ value = ((value << 8) | byte) & 0xffff;
63
+ bits += 8;
64
+ while (bits >= 5) {
65
+ out += BASE32[(value >>> (bits - 5)) & 31];
66
+ bits -= 5;
67
+ }
68
+ }
69
+ return out.slice(0, ACCESS_TOKEN_LENGTH);
70
+ }
71
+ /** Evidence for a failure report: the last `n` non-empty lines, bounded. */
72
+ export function nonEmptyTail(text, n = 3, maxLen = 300) {
73
+ return text.split("\n").map(l => l.trim()).filter(Boolean).slice(-n).join(" / ").slice(0, maxLen);
74
+ }
75
+ // ── Session ──────────────────────────────────────────────────────────────────
76
+ export class WebTerminalSession extends EventEmitter {
77
+ spec;
78
+ events;
79
+ backend;
80
+ logger;
81
+ now;
82
+ sid = randomBytes(16).toString("hex");
83
+ socketName;
84
+ createdAt;
85
+ expiresAt = 0;
86
+ state = "created";
87
+ accessToken = generateAccessToken();
88
+ tokenAttempts = 0;
89
+ cookieValue = null;
90
+ replay = [];
91
+ replayBytes = 0;
92
+ replayTruncated = false;
93
+ client = null;
94
+ ttlTimer = null;
95
+ pollTimer = null;
96
+ polling = false;
97
+ finishing = null;
98
+ sentUrls = new Set();
99
+ successSeenAt = null;
100
+ /** Consecutive polls where tmux could not even be asked — a vanished server must not idle until TTL. */
101
+ probeFailures = 0;
102
+ /** Geometry most recently requested by the browser (queued, frozen or applied) — the only dedupe key. */
103
+ lastRequested = null;
104
+ /** Single FIFO for input + resize: browser order is pane order (B4). */
105
+ ioQueue = Promise.resolve();
106
+ pendingInputBytes = 0;
107
+ pendingJobCount = 0;
108
+ /** Input bytes not yet handed to a running job: consecutive frames coalesce into one tmux paste. */
109
+ pendingBatch = null;
110
+ /**
111
+ * The resize job at the TAIL of the queue, still open for coalescing. Only
112
+ * while no input has been queued after it may a newer resize update it;
113
+ * input freezes it (a resize is a barrier and must stay in its FIFO slot).
114
+ */
115
+ tailResize = null;
116
+ cols;
117
+ rows;
118
+ constructor(spec, events, backend, logger, now = Date.now) {
119
+ super();
120
+ this.spec = spec;
121
+ this.events = events;
122
+ this.backend = backend;
123
+ this.logger = logger;
124
+ this.now = now;
125
+ this.socketName = `agend-term-${this.sid.slice(0, 12)}`;
126
+ this.createdAt = this.now();
127
+ this.cols = clamp(spec.cols ?? DEFAULT_COLS, MIN_COLS, MAX_COLS);
128
+ this.rows = clamp(spec.rows ?? DEFAULT_ROWS, MIN_ROWS, MAX_ROWS);
129
+ if (!Number.isFinite(spec.ttlMs) || spec.ttlMs <= 0 || spec.ttlMs > MAX_TTL_MS) {
130
+ throw new Error(`ttlMs must be within (0, ${MAX_TTL_MS}]`);
131
+ }
132
+ }
133
+ /** The one-time access token, readable only until it is redeemed or the session ends. */
134
+ peekAccessToken() { return this.accessToken; }
135
+ get ttlRemainingMs() { return Math.max(0, this.expiresAt - this.now()); }
136
+ async start() {
137
+ if (this.state !== "created")
138
+ throw new Error("session already started");
139
+ this.state = "running";
140
+ this.expiresAt = this.now() + this.spec.ttlMs;
141
+ try {
142
+ await this.backend.start({
143
+ socket: this.socketName,
144
+ command: this.spec.command,
145
+ cwd: this.spec.cwd,
146
+ cols: this.cols,
147
+ rows: this.rows,
148
+ onOutput: chunk => this.onOutput(chunk),
149
+ });
150
+ }
151
+ catch (err) {
152
+ this.state = "finished";
153
+ this.accessToken = null;
154
+ this.audit("web_terminal_start_failed", { error: err.message, cleanupFailed: err.cleanupFailed === true });
155
+ throw err;
156
+ }
157
+ this.audit("web_terminal_created", { ttlMs: this.spec.ttlMs, cols: this.cols, rows: this.rows });
158
+ this.ttlTimer = setTimeout(() => { void this.finish({ ok: false, reason: "ttl", detail: "time limit reached" }); }, this.spec.ttlMs);
159
+ this.ttlTimer.unref?.();
160
+ this.schedulePoll();
161
+ }
162
+ // ── Token gate ──
163
+ /**
164
+ * Redeem the one-time access token. Constant-time compare; three failures
165
+ * destroy the session (URL + attempts = suspected leak). Success returns the
166
+ * cookie value the HTTP layer sets; the token is gone from memory afterwards.
167
+ */
168
+ redeemToken(candidate) {
169
+ if (this.state !== "running")
170
+ return { result: "finished" };
171
+ if (this.accessToken === null)
172
+ return { result: "used" };
173
+ const expected = Buffer.from(this.accessToken, "ascii");
174
+ const given = Buffer.alloc(expected.length);
175
+ const normalized = candidate.trim().toUpperCase().replace(/[\s-]/g, "");
176
+ Buffer.from(normalized, "ascii").copy(given, 0, 0, Math.min(normalized.length, expected.length));
177
+ const equal = timingSafeEqual(expected, given) && normalized.length === expected.length;
178
+ if (!equal) {
179
+ this.tokenAttempts++;
180
+ const remaining = MAX_TOKEN_ATTEMPTS - this.tokenAttempts;
181
+ this.audit("web_terminal_token_failed", { attempts: this.tokenAttempts });
182
+ if (remaining <= 0) {
183
+ this.audit("web_terminal_token_lockout", {});
184
+ void this.finish({ ok: false, reason: "token_lockout", detail: "access token failed 3 times — link may have leaked" });
185
+ return { result: "locked" };
186
+ }
187
+ return { result: "bad", remaining };
188
+ }
189
+ this.accessToken = null;
190
+ this.cookieValue = randomBytes(32).toString("hex");
191
+ this.audit("web_terminal_opened", {});
192
+ return { result: "ok", cookie: this.cookieValue };
193
+ }
194
+ checkCookie(value) {
195
+ if (!value || !this.cookieValue || this.state !== "running")
196
+ return false;
197
+ const a = Buffer.from(this.cookieValue, "ascii");
198
+ const b = Buffer.alloc(a.length);
199
+ Buffer.from(value, "ascii").copy(b, 0, 0, Math.min(value.length, a.length));
200
+ return timingSafeEqual(a, b) && value.length === a.length;
201
+ }
202
+ // ── Browser I/O ──
203
+ /** Attach the (single) browser; replays buffered output first. Returns a detach function. */
204
+ attachClient(client) {
205
+ if (this.client && this.client !== client) {
206
+ try {
207
+ this.client.close(4000, "replaced by a newer connection");
208
+ }
209
+ catch { /* gone */ }
210
+ }
211
+ this.client = client;
212
+ client.send(JSON.stringify({
213
+ t: "hello", backend: this.spec.backend, kind: this.spec.kind, command: this.spec.command,
214
+ ttlRemainingMs: this.ttlRemainingMs, cols: this.cols, rows: this.rows, truncated: this.replayTruncated,
215
+ }));
216
+ for (const chunk of this.replay)
217
+ client.send(chunk);
218
+ return () => { if (this.client === client)
219
+ this.client = null; };
220
+ }
221
+ /** Bytes queued for the pane but not yet delivered. */
222
+ get pendingInput() { return this.pendingInputBytes; }
223
+ /** Queued tmux operations not yet started (tests: must stay small under any input pattern). */
224
+ get pendingJobs() { return this.pendingJobCount; }
225
+ /**
226
+ * Queue browser input for the pane. Strictly ordered with resize.
227
+ * Consecutive input frames coalesce into ONE paste (a resize is a barrier),
228
+ * so the number of tmux operations is bounded by the number of barriers,
229
+ * not by the number of keystrokes. Returns false when the session is not
230
+ * running. A backlog beyond MAX_PENDING_INPUT_BYTES / MAX_PENDING_JOBS
231
+ * means tmux is wedged: the session ENDS (fail closed) rather than letting
232
+ * queued keystrokes reach a credential prompt unattended.
233
+ */
234
+ input(bytes) {
235
+ if (this.state !== "running")
236
+ return false;
237
+ if (bytes.length === 0)
238
+ return true;
239
+ if (this.pendingInputBytes + bytes.length > MAX_PENDING_INPUT_BYTES) {
240
+ void this.finish({ ok: false, reason: "error", detail: "terminal input backlog — tmux not accepting input" });
241
+ return false;
242
+ }
243
+ this.pendingInputBytes += bytes.length;
244
+ this.tailResize = null; // input after a resize freezes that resize in place
245
+ if (this.pendingBatch) {
246
+ this.pendingBatch.push(Buffer.from(bytes));
247
+ return true;
248
+ }
249
+ const batch = [Buffer.from(bytes)];
250
+ this.pendingBatch = batch;
251
+ this.enqueue(async () => {
252
+ if (this.pendingBatch === batch)
253
+ this.pendingBatch = null; // later frames start a new batch
254
+ const payload = Buffer.concat(batch);
255
+ try {
256
+ if (this.state === "running")
257
+ await this.backend.sendInput(this.socketName, payload);
258
+ }
259
+ catch (err) {
260
+ // Fail closed (B1): a partially delivered keystroke sequence must not
261
+ // leave the admin typing into an unknown state. Sanitized detail only.
262
+ this.logger.warn({ sid: this.sid, op: "input", code: err?.code }, "web terminal tmux operation failed");
263
+ void this.finish({ ok: false, reason: "error", detail: "terminal input failed — session ended" });
264
+ }
265
+ finally {
266
+ this.pendingInputBytes -= payload.length;
267
+ }
268
+ }, "input");
269
+ return true;
270
+ }
271
+ resize(cols, rows) {
272
+ if (this.state !== "running")
273
+ return;
274
+ const c = clamp(Math.floor(cols), MIN_COLS, MAX_COLS);
275
+ const r = clamp(Math.floor(rows), MIN_ROWS, MAX_ROWS);
276
+ // Dedupe only against what the browser LAST asked for (queued, frozen or
277
+ // applied) — never against the committed size, which may be stale while
278
+ // an earlier resize is still waiting in the queue.
279
+ if (this.lastRequested && this.lastRequested.cols === c && this.lastRequested.rows === r)
280
+ return;
281
+ this.lastRequested = { cols: c, rows: r };
282
+ if (this.tailResize) {
283
+ this.tailResize.cols = c;
284
+ this.tailResize.rows = r;
285
+ return;
286
+ } // newest geometry wins — same FIFO slot
287
+ const target = { cols: c, rows: r };
288
+ this.tailResize = target;
289
+ this.pendingBatch = null; // a resize is an ordering barrier for input
290
+ this.enqueue(async () => {
291
+ if (this.tailResize === target)
292
+ this.tailResize = null;
293
+ if (this.state !== "running")
294
+ return;
295
+ try {
296
+ await this.backend.resize(this.socketName, target.cols, target.rows);
297
+ this.cols = target.cols;
298
+ this.rows = target.rows; // committed only on success (retry stays possible)
299
+ }
300
+ catch (err) {
301
+ this.logger.warn({ sid: this.sid, op: "resize", code: err?.code }, "web terminal tmux operation failed");
302
+ // Not applied: forget it as "last requested" so the same size can be retried.
303
+ if (this.lastRequested && this.lastRequested.cols === target.cols && this.lastRequested.rows === target.rows)
304
+ this.lastRequested = null;
305
+ }
306
+ }, "resize");
307
+ }
308
+ /** Everything queued before the returned promise settles has reached tmux (tests). */
309
+ drain() { return this.ioQueue; }
310
+ enqueue(job, what) {
311
+ if (this.pendingJobCount >= MAX_PENDING_JOBS) {
312
+ void this.finish({ ok: false, reason: "error", detail: `terminal ${what} backlog — tmux not responding` });
313
+ return;
314
+ }
315
+ this.pendingJobCount++;
316
+ this.ioQueue = this.ioQueue
317
+ .then(() => { this.pendingJobCount--; return job(); })
318
+ .catch(err => {
319
+ // Never log the error text: a failed tmux invocation must not leak what
320
+ // was being typed. Operation name only.
321
+ this.logger.warn({ sid: this.sid, op: what, code: err?.code }, "web terminal tmux operation failed");
322
+ });
323
+ }
324
+ cancel(detail = "cancelled") {
325
+ return this.finish({ ok: false, reason: "cancel", detail });
326
+ }
327
+ // ── Internals ──
328
+ onOutput(chunk) {
329
+ this.replay.push(chunk);
330
+ this.replayBytes += chunk.length;
331
+ while (this.replayBytes > REPLAY_BUFFER_LIMIT && this.replay.length > 1) {
332
+ this.replayBytes -= this.replay.shift().length;
333
+ this.replayTruncated = true;
334
+ }
335
+ this.client?.send(chunk);
336
+ }
337
+ schedulePoll() {
338
+ if (this.state !== "running")
339
+ return;
340
+ this.pollTimer = setTimeout(() => { void this.poll(); }, POLL_INTERVAL_MS);
341
+ this.pollTimer.unref?.();
342
+ }
343
+ /** Exposed for tests; the timer calls this every second. */
344
+ async poll() {
345
+ if (this.state !== "running" || this.polling)
346
+ return;
347
+ this.polling = true;
348
+ try {
349
+ const status = await this.backend.paneStatus(this.socketName).catch(() => null);
350
+ const pane = await this.backend.capture(this.socketName).catch(() => "");
351
+ this.observe(pane);
352
+ if (status === null) {
353
+ // tmux did not answer. One miss can be load; three in a row means the
354
+ // dedicated server is gone or wedged — end now, fail closed, rather
355
+ // than keep a token-gated terminal "running" on nothing until TTL.
356
+ if (++this.probeFailures >= MAX_PROBE_FAILURES) {
357
+ await this.finish({ ok: false, reason: "error", detail: "terminal backend unreachable — session ended" });
358
+ return;
359
+ }
360
+ }
361
+ else {
362
+ this.probeFailures = 0;
363
+ }
364
+ if (status && !status.alive) {
365
+ await this.finishFromExit(status.exitCode, pane);
366
+ return;
367
+ }
368
+ if (this.successSeenAt !== null && this.now() - this.successSeenAt > SUCCESS_EXIT_GRACE_MS) {
369
+ // The CLI printed its success line but keeps running (some stay in a
370
+ // TUI). The purpose is achieved; don't make the admin wait for TTL.
371
+ await this.finish({ ok: true, reason: "exit", detail: "success reported" });
372
+ return;
373
+ }
374
+ }
375
+ finally {
376
+ this.polling = false;
377
+ }
378
+ this.schedulePoll();
379
+ }
380
+ observe(pane) {
381
+ const obs = this.spec.observe;
382
+ if (!obs || !pane)
383
+ return;
384
+ const urlMatch = pane.match(obs.urlPattern ?? GENERIC_URL);
385
+ if (urlMatch) {
386
+ const url = urlMatch[0].replace(/[.,]+$/, "");
387
+ if (!this.sentUrls.has(url)) {
388
+ this.sentUrls.add(url);
389
+ const codeMatch = obs.codePattern ? pane.match(obs.codePattern) : null;
390
+ const code = codeMatch ? codeMatch.slice(1).find(g => g !== undefined) ?? null : null;
391
+ this.audit("web_terminal_hint", { host: safeHost(url), hasCode: code !== null });
392
+ void Promise.resolve(this.events.onHint?.(url, code)).catch(err => this.logger.warn({ err: err.message }, "web terminal hint handler failed"));
393
+ }
394
+ }
395
+ if (obs.successPattern && this.successSeenAt === null && obs.successPattern.test(pane)) {
396
+ this.successSeenAt = this.now();
397
+ this.audit("web_terminal_success_seen", {});
398
+ }
399
+ }
400
+ async finishFromExit(exitCode, pane) {
401
+ const obs = this.spec.observe;
402
+ const success = this.successSeenAt !== null || (obs?.successPattern ? obs.successPattern.test(pane) : false);
403
+ if (exitCode === 0 || success) {
404
+ await this.finish({ ok: true, reason: "exit", exitCode, detail: success ? "success reported" : "clean exit" });
405
+ return;
406
+ }
407
+ const tail = nonEmptyTail(pane);
408
+ const known = obs?.failures?.find(f => f.pattern.test(pane));
409
+ await this.finish({
410
+ ok: false, reason: "exit", exitCode,
411
+ detail: known ? known.message : `exited with code ${exitCode ?? "?"}${tail ? ` — ${tail}` : ""}`,
412
+ suggest: known?.suggest,
413
+ });
414
+ }
415
+ finish(result) {
416
+ if (this.finishing)
417
+ return this.finishing;
418
+ this.finishing = (async () => {
419
+ this.state = "finished";
420
+ this.accessToken = null;
421
+ this.cookieValue = null;
422
+ if (this.ttlTimer)
423
+ clearTimeout(this.ttlTimer);
424
+ if (this.pollTimer)
425
+ clearTimeout(this.pollTimer);
426
+ try {
427
+ this.client?.send(JSON.stringify({ t: "exit", ok: result.ok, reason: result.reason, exitCode: result.exitCode, detail: result.detail }));
428
+ }
429
+ catch { /* gone */ }
430
+ try {
431
+ this.client?.close(1000, result.reason);
432
+ }
433
+ catch { /* gone */ }
434
+ this.client = null;
435
+ // kill() resolves only when the dedicated server is CONFIRMED gone (B2).
436
+ // A rejection or a timeout means the command may still be running: say so
437
+ // loudly (audit + result flag) rather than pretending the boundary held.
438
+ const killed = await Promise.race([
439
+ this.backend.kill(this.socketName).then(() => true, () => false),
440
+ new Promise(resolve => setTimeout(() => resolve(false), KILL_TIMEOUT_MS).unref?.()),
441
+ ]);
442
+ if (!killed) {
443
+ result.cleanupFailed = true;
444
+ this.logger.warn({ sid: this.sid, socket: this.socketName }, "web terminal tmux cleanup failed — server may still be alive");
445
+ this.audit("web_terminal_cleanup_failed", { socket: this.socketName });
446
+ }
447
+ this.audit("web_terminal_closed", { reason: result.reason, ok: result.ok, exitCode: result.exitCode, cleanupFailed: result.cleanupFailed === true });
448
+ this.emit("finished", result);
449
+ await Promise.resolve(this.events.onDone(result)).catch(err => this.logger.warn({ err: err.message }, "web terminal done handler failed"));
450
+ })();
451
+ return this.finishing;
452
+ }
453
+ audit(event, fields) {
454
+ const base = { sid: this.sid, kind: this.spec.kind, backend: this.spec.backend, requester: this.spec.requester.userId };
455
+ this.logger.info({ ...base, ...fields }, event);
456
+ try {
457
+ this.events.onAudit?.(event, { ...base, ...fields });
458
+ }
459
+ catch { /* audit must never break the session */ }
460
+ }
461
+ }
462
+ function clamp(n, lo, hi) {
463
+ return Math.min(hi, Math.max(lo, Number.isFinite(n) ? n : lo));
464
+ }
465
+ function safeHost(url) {
466
+ try {
467
+ return new URL(url).host;
468
+ }
469
+ catch {
470
+ return "?";
471
+ }
472
+ }
473
+ // ── Real tmux backend ────────────────────────────────────────────────────────
474
+ /**
475
+ * Drives a dedicated tmux server (`-L <socket>`, `-f /dev/null` so the user's
476
+ * tmux.conf cannot alter behaviour). Output: pipe-pane → FIFO held O_RDWR.
477
+ */
478
+ export class TmuxTerminalBackend {
479
+ tmuxBin;
480
+ streams = new Map();
481
+ /**
482
+ * Identity of each dedicated server, captured right after new-session: the
483
+ * PID plus an immutable process-generation fingerprint (Linux /proc start
484
+ * time). There is no weaker fallback: on platforms without /proc no
485
+ * identity is recorded and no signal is ever sent. A bare PID may be reused
486
+ * by the OS once the server dies outside our control; a signal must NEVER
487
+ * be sent unless the fingerprint still matches — otherwise the "one
488
+ * command" scope would be violated against an unrelated process.
489
+ */
490
+ servers = new Map();
491
+ probe;
492
+ constructor(tmuxBin = "tmux", opts = {}) {
493
+ this.tmuxBin = tmuxBin;
494
+ this.probe = opts.probeProcess ?? probeProcess;
495
+ }
496
+ /** Test seam: the recorded server identity, if a signal fallback was registered. */
497
+ serverRecordForTests(socket) {
498
+ return this.servers.get(socket);
499
+ }
500
+ /**
501
+ * Run one tmux command. Errors are re-thrown SANITIZED: operation name,
502
+ * socket and exit code only — never the argv, which for input would be the
503
+ * user's keystrokes (B2), and never tmux's stderr, which echoes the command.
504
+ */
505
+ async tmux(socket, op, args, input) {
506
+ return new Promise((resolve, reject) => {
507
+ const child = execFile(this.tmuxBin, ["-L", socket, op, ...args], { encoding: "utf8", timeout: TMUX_EXEC_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
508
+ if (error) {
509
+ const code = error.code;
510
+ const e = new Error(`tmux ${op} failed (socket ${socket}${typeof code === "number" || typeof code === "string" ? `, ${code}` : ""})`);
511
+ e.code = code;
512
+ reject(e);
513
+ return;
514
+ }
515
+ resolve(typeof stdout === "string" ? stdout : String(stdout));
516
+ });
517
+ if (input) {
518
+ child.stdin?.on("error", () => { });
519
+ child.stdin?.end(input);
520
+ }
521
+ else {
522
+ child.stdin?.end();
523
+ }
524
+ });
525
+ }
526
+ async start(opts) {
527
+ const { socket } = opts;
528
+ // Placeholder first, pipe second, real command third — so no byte is lost.
529
+ await new Promise((resolve, reject) => execFile(this.tmuxBin, ["-L", socket, "-f", "/dev/null", "new-session", "-d", "-s", "main",
530
+ "-x", String(opts.cols), "-y", String(opts.rows), "-c", opts.cwd, "sleep 86400"], { timeout: TMUX_EXEC_TIMEOUT_MS }, err => err ? reject(new Error(`tmux new-session failed (socket ${socket})`)) : resolve()));
531
+ // From here on the server exists: every later failure must tear it down
532
+ // (B5) — set-option, mkdtemp, mkfifo, open, pipe-pane, respawn alike.
533
+ let dir = null;
534
+ let stream = null;
535
+ try {
536
+ const pid = Number.parseInt((await this.tmux(socket, "display-message", ["-p", "#{pid}"])).trim(), 10);
537
+ if (Number.isFinite(pid) && pid > 1) {
538
+ const probe = this.probe(pid);
539
+ // Only a STRONG generation fingerprint may back a signal fallback. On
540
+ // platforms without one (no /proc start time) there is no fallback at
541
+ // all: kill-server or a loud cleanupFailed — never a guess.
542
+ if (probe.kind === "identified")
543
+ this.servers.set(socket, { pid, identity: probe.identity });
544
+ }
545
+ await this.tmux(socket, "set-option", ["-g", "window-size", "manual"]);
546
+ await this.tmux(socket, "set-option", ["-g", "remain-on-exit", "on"]);
547
+ await this.tmux(socket, "set-option", ["-g", "history-limit", "2000"]);
548
+ dir = mkdtempSync(join(tmpdir(), "agend-term-"));
549
+ const fifo = join(dir, "out");
550
+ await new Promise((resolve, reject) => execFile("mkfifo", ["-m", "600", fifo], { timeout: TMUX_EXEC_TIMEOUT_MS }, err => err ? reject(new Error("mkfifo failed")) : resolve()));
551
+ // O_RDWR: we are always a writer too, so the FIFO never reports EOF when
552
+ // tmux's `cat` closes. A net.Socket over the fd gives event-driven reads
553
+ // (fs.ReadStream would abort with EAGAIN on a non-blocking pipe).
554
+ const fd = openSync(fifo, fsConstants.O_RDWR | fsConstants.O_NONBLOCK);
555
+ stream = new NetSocket({ fd, readable: true, writable: false });
556
+ stream.on("data", (chunk) => opts.onOutput(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
557
+ stream.on("error", () => { });
558
+ const theStream = stream;
559
+ const theDir = dir;
560
+ let stopped = false;
561
+ const stop = () => {
562
+ if (stopped)
563
+ return;
564
+ stopped = true;
565
+ theStream.destroy(); // closes fd
566
+ rmSync(theDir, { recursive: true, force: true });
567
+ };
568
+ this.streams.set(socket, { dir, stop });
569
+ await this.tmux(socket, "pipe-pane", ["-o", "-t", "main", `cat >> ${shellQuote(fifo)}`]);
570
+ await this.tmux(socket, "respawn-pane", ["-k", "-t", "main", "-c", opts.cwd, `sh -c ${shellQuote(opts.command)}`]);
571
+ }
572
+ catch (err) {
573
+ stream?.destroy();
574
+ if (dir)
575
+ rmSync(dir, { recursive: true, force: true });
576
+ this.streams.delete(socket);
577
+ try {
578
+ await this.kill(socket);
579
+ }
580
+ catch {
581
+ err.cleanupFailed = true;
582
+ err.message += " (cleanup failed: the tmux server may still be running)";
583
+ }
584
+ throw err;
585
+ }
586
+ }
587
+ /**
588
+ * Deliver bytes to the pane WITHOUT putting typed text in any argv (B2).
589
+ *
590
+ * Two transports, chosen per run of bytes (see segmentInput):
591
+ * - text runs (anything a user could be typing as a secret) go to tmux
592
+ * over stdin into a named buffer and are pasted with `paste-buffer -r`
593
+ * (raw: bytes unchanged), `-d` deleting the buffer;
594
+ * - control runs (0x00–0x1f, 0x7f and complete ESC sequences: arrows,
595
+ * Enter, Tab, Ctrl-C…) go through `send-keys -H`. They carry no
596
+ * secret, and this is the only path on which the pane's tty performs
597
+ * signal handling: a pasted 0x03 is echoed but does NOT raise SIGINT
598
+ * (verified live), a sent one does.
599
+ */
600
+ async sendInput(socket, bytes) {
601
+ if (bytes.length === 0)
602
+ return;
603
+ const name = `agend-in-${socket.slice(-12)}`;
604
+ for (const run of segmentInput(bytes)) {
605
+ if (run.kind === "control") {
606
+ const hex = [];
607
+ for (const b of run.bytes)
608
+ hex.push(b.toString(16).padStart(2, "0"));
609
+ await this.tmux(socket, "send-keys", ["-H", "-t", "main", ...hex]);
610
+ continue;
611
+ }
612
+ try {
613
+ await this.tmux(socket, "load-buffer", ["-b", name, "-"], run.bytes);
614
+ await this.tmux(socket, "paste-buffer", ["-d", "-r", "-b", name, "-t", "main"]);
615
+ }
616
+ catch (err) {
617
+ await this.tmux(socket, "delete-buffer", ["-b", name]).catch(() => { });
618
+ throw err;
619
+ }
620
+ }
621
+ }
622
+ async resize(socket, cols, rows) {
623
+ await this.tmux(socket, "resize-window", ["-t", "main", "-x", String(cols), "-y", String(rows)]);
624
+ }
625
+ async capture(socket) {
626
+ return this.tmux(socket, "capture-pane", ["-p", "-J", "-t", "main", "-S", "-200"]);
627
+ }
628
+ async paneStatus(socket) {
629
+ try {
630
+ const stdout = await this.tmux(socket, "display-message", ["-p", "-t", "main", "#{pane_dead} #{pane_dead_status}"]);
631
+ const [dead, status] = stdout.trim().split(/\s+/);
632
+ if (dead === "1") {
633
+ const code = Number.parseInt(status ?? "", 10);
634
+ return { alive: false, exitCode: Number.isFinite(code) ? code : undefined };
635
+ }
636
+ return { alive: true };
637
+ }
638
+ catch {
639
+ return null;
640
+ }
641
+ }
642
+ /**
643
+ * Tri-state liveness of the dedicated server. "dead" is asserted only on
644
+ * POSITIVE evidence of absence (tmux's own no-server answer, and — when a
645
+ * PID is known — the process gone); a probe that could not execute
646
+ * (spawn failure, timeout, unexpected exit) is "unknown", never "dead".
647
+ * stderr is inspected in memory only and never logged.
648
+ */
649
+ async serverState(socket) {
650
+ const server = this.servers.get(socket);
651
+ // Tri-state PID evidence: true = still OUR process; false = positively
652
+ // dead (ESRCH, or a strong fingerprint mismatch = PID reused); null = the
653
+ // probe could not determine anything (never treated as dead).
654
+ let pidAlive = null;
655
+ if (server) {
656
+ const now = this.probe(server.pid);
657
+ if (now.kind === "gone")
658
+ pidAlive = false;
659
+ else if (now.kind === "identified") {
660
+ if (now.identity === server.identity)
661
+ pidAlive = true;
662
+ else {
663
+ pidAlive = false;
664
+ this.servers.delete(socket);
665
+ } // PID reused by someone else: our server is dead
666
+ }
667
+ }
668
+ const pid = server?.pid;
669
+ const tmuxSays = await new Promise(resolve => {
670
+ execFile(this.tmuxBin, ["-L", socket, "list-sessions"], { encoding: "utf8", timeout: TMUX_EXEC_TIMEOUT_MS }, (error, _stdout, stderr) => {
671
+ if (!error) {
672
+ resolve("alive");
673
+ return;
674
+ }
675
+ const code = error.code;
676
+ const text = String(stderr ?? "");
677
+ // tmux 3.x: "no server running on <path>" / "error connecting to <path> (No such file or directory)"
678
+ if (code === 1 && /no server running|error connecting to .*No such file or directory/.test(text)) {
679
+ resolve("dead");
680
+ return;
681
+ }
682
+ resolve("unknown");
683
+ });
684
+ });
685
+ if (tmuxSays === "alive" || pidAlive === true)
686
+ return "alive"; // any positive sign of life wins
687
+ if (pidAlive === false)
688
+ return "dead"; // the server process is positively gone / reused
689
+ if (tmuxSays === "dead" && !pid)
690
+ return "dead"; // tmux's own no-server answer, nothing recorded to contradict it
691
+ return "unknown"; // anything undeterminable → never "absent"
692
+ }
693
+ /**
694
+ * Kill the dedicated server and CONFIRM it is gone (B2). `kill-server` is
695
+ * tried twice; if the server is not positively dead, the PID captured at
696
+ * start is sent SIGTERM then SIGKILL. The FINAL probe decides: resolves
697
+ * only on "dead"; "alive" and "unknown" both reject so the caller reports
698
+ * a cleanup failure instead of claiming the boundary held. "Already gone"
699
+ * (positively) is success.
700
+ */
701
+ async kill(socket) {
702
+ const s = this.streams.get(socket);
703
+ if (s) {
704
+ s.stop();
705
+ this.streams.delete(socket);
706
+ }
707
+ const server = this.servers.get(socket);
708
+ let state = "unknown";
709
+ for (let attempt = 0; attempt < 2 && state !== "dead"; attempt++) {
710
+ await this.tmux(socket, "kill-server", []).catch(() => { });
711
+ state = await this.serverState(socket);
712
+ }
713
+ if (state !== "dead" && server) {
714
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
715
+ // Re-verify identity immediately before EVERY signal: only an
716
+ // identified process with the exact recorded fingerprint is ours.
717
+ const now = this.probe(server.pid);
718
+ if (now.kind !== "identified" || now.identity !== server.identity)
719
+ break;
720
+ try {
721
+ process.kill(server.pid, signal);
722
+ }
723
+ catch { /* ESRCH: gone */ }
724
+ await new Promise(r => setTimeout(r, 300));
725
+ state = await this.serverState(socket);
726
+ if (state === "dead")
727
+ break;
728
+ }
729
+ }
730
+ if (state !== "dead") {
731
+ // Keep the identity so a later retry can still reach the process.
732
+ throw new Error(`tmux server on socket ${socket} could not be confirmed dead (${state})`);
733
+ }
734
+ this.servers.delete(socket);
735
+ }
736
+ /** Test seam: adopt a server identity as if captured at start. */
737
+ rememberServerForTests(socket, pid, identity) {
738
+ this.servers.set(socket, { pid, identity });
739
+ }
740
+ }
741
+ /**
742
+ * Tri-state process probe. "gone" requires positive ESRCH evidence. The
743
+ * identity is the process start time in clock ticks since boot (Linux
744
+ * /proc/<pid>/stat field 22) and NOTHING else: it is fixed for the life of
745
+ * the process, so a mismatch is proof of PID reuse. The command name is
746
+ * returned separately for diagnostics only — a live process can rename
747
+ * itself (prctl / /proc/self/comm), so it must never take part in equality.
748
+ * On platforms without /proc there is NO fingerprint: the probe can only say
749
+ * gone/unknown, and the backend registers no signal fallback.
750
+ */
751
+ export function probeProcess(pid) {
752
+ if (!Number.isFinite(pid) || pid <= 1)
753
+ return { kind: "unknown" };
754
+ const exists = () => {
755
+ try {
756
+ process.kill(pid, 0);
757
+ return true;
758
+ }
759
+ catch (err) {
760
+ return err.code === "ESRCH" ? false : null; // EPERM etc.: exists but not ours to know
761
+ }
762
+ };
763
+ if (process.platform !== "linux") {
764
+ const e = exists();
765
+ return e === false ? { kind: "gone" } : { kind: "unknown" };
766
+ }
767
+ let stat;
768
+ try {
769
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
770
+ }
771
+ catch (err) {
772
+ if (err.code === "ENOENT" && exists() === false)
773
+ return { kind: "gone" };
774
+ return { kind: "unknown" }; // transient read failure, EACCES, or a race
775
+ }
776
+ const open = stat.indexOf("(");
777
+ const close = stat.lastIndexOf(")");
778
+ if (open < 0 || close < open)
779
+ return { kind: "unknown" };
780
+ const comm = stat.slice(open + 1, close);
781
+ const rest = stat.slice(close + 2).split(" "); // rest[0] = field 3 (state) … rest[19] = field 22 (starttime)
782
+ const starttime = rest[19];
783
+ if (!starttime || !/^\d+$/.test(starttime))
784
+ return { kind: "unknown" };
785
+ return { kind: "identified", identity: `linux:${starttime}`, comm };
786
+ }
787
+ /**
788
+ * Split browser input into runs: "control" (C0 bytes, DEL, and complete ESC
789
+ * sequences — never secrets) vs "text" (everything else — possibly a
790
+ * password). Control runs may travel in argv; text runs must not.
791
+ */
792
+ export function segmentInput(bytes) {
793
+ const runs = [];
794
+ let i = 0;
795
+ const isControl = (b) => b < 0x20 || b === 0x7f;
796
+ while (i < bytes.length) {
797
+ const start = i;
798
+ if (isControl(bytes[i])) {
799
+ while (i < bytes.length && isControl(bytes[i])) {
800
+ if (bytes[i] === 0x1b) {
801
+ // ESC sequence: ESC [ params… final | ESC O final | ESC <single>
802
+ let j = i + 1;
803
+ if (j < bytes.length && (bytes[j] === 0x5b || bytes[j] === 0x4f)) { // '[' or 'O'
804
+ j++;
805
+ while (j < bytes.length && bytes[j] >= 0x20 && bytes[j] <= 0x3f)
806
+ j++; // parameters/intermediates
807
+ if (j < bytes.length && bytes[j] >= 0x40 && bytes[j] <= 0x7e)
808
+ j++; // final byte
809
+ }
810
+ else if (j < bytes.length && bytes[j] >= 0x20 && bytes[j] <= 0x7e) {
811
+ j++; // ESC + one char (alt-key)
812
+ }
813
+ i = j;
814
+ }
815
+ else {
816
+ i++;
817
+ }
818
+ }
819
+ runs.push({ kind: "control", bytes: Buffer.from(bytes.subarray(start, i)) });
820
+ }
821
+ else {
822
+ while (i < bytes.length && !isControl(bytes[i]))
823
+ i++;
824
+ runs.push({ kind: "text", bytes: Buffer.from(bytes.subarray(start, i)) });
825
+ }
826
+ }
827
+ return runs;
828
+ }
829
+ /** Single-quote for `sh`: the only quoting that survives any content. */
830
+ export function shellQuote(s) {
831
+ return `'${s.replace(/'/g, `'\\''`)}'`;
832
+ }
833
+ //# sourceMappingURL=web-terminal.js.map