@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.
- package/dist/ui/web-terminal/terminal.css +17 -0
- package/dist/ui/web-terminal/terminal.html +43 -0
- package/dist/ui/web-terminal/terminal.js +148 -0
- package/dist/ui/web-terminal/vendor/LICENSE.addon-fit +19 -0
- package/dist/ui/web-terminal/vendor/LICENSE.addon-web-links +19 -0
- package/dist/ui/web-terminal/vendor/LICENSE.xterm +21 -0
- package/dist/ui/web-terminal/vendor/VENDORED.md +22 -0
- package/dist/ui/web-terminal/vendor/addon-fit.js +1 -0
- package/dist/ui/web-terminal/vendor/addon-web-links.js +1 -0
- package/dist/ui/web-terminal/vendor/xterm.css +285 -0
- package/dist/ui/web-terminal/vendor/xterm.js +1 -0
- package/dist/web-terminal-http.d.ts +53 -0
- package/dist/web-terminal-http.js +396 -0
- package/dist/web-terminal-http.js.map +1 -0
- package/dist/web-terminal.d.ts +329 -0
- package/dist/web-terminal.js +833 -0
- package/dist/web-terminal.js.map +1 -0
- package/dist/ws-server.d.ts +69 -0
- package/dist/ws-server.js +392 -0
- package/dist/ws-server.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|