@songsid/agend 2.1.4 → 2.1.5-beta.2

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