oxedyne/daimond/www/js/handpty.js
28.0 KiB, 1 run
created by r2519314175:1379, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* handpty.js — the machine hand's terminal relay, page side. |
| 2 | * |
| 3 | * `window.DaimondPty` is to a terminal what `window.DaimondHand` is to a |
| 4 | * command: the ONE interface above the wire, so the thing drawing the screen |
| 5 | * never learns which transport is attached. It is the sibling of hand.js and it |
| 6 | * shares hand.js's link — see "One link, shared" below — because a terminal |
| 7 | * session is not a second kind of connection to the machine, it is a second kind |
| 8 | * of conversation on the one that already exists. |
| 9 | * |
| 10 | * ── Why a terminal is not an exec ─────────────────────────────────── |
| 11 | * |
| 12 | * `Req::Exec` decides its input before the command starts and reads its output |
| 13 | * afterwards. That covers nearly everything an agent does. It cannot cover |
| 14 | * `sudo` asking for a password, `ssh` asking for a passphrase, or anything that |
| 15 | * asks the kernel whether it is talking to a terminal and behaves differently |
| 16 | * when it is not. Those need a pty, and a pty is a conversation: bytes both |
| 17 | * ways, for as long as the program lives, with a size the kernel has to be told |
| 18 | * about and told again. |
| 19 | * |
| 20 | * ── Bytes, not text ───────────────────────────────────────────────── |
| 21 | * |
| 22 | * `output` arrives as base64 and is decoded HERE, once, at this boundary. What |
| 23 | * a subscriber receives is a `Uint8Array` of exactly what the program wrote. |
| 24 | * Nothing above this line ever sees base64, and nothing below it ever sees a |
| 25 | * string: a pty carries a `cat` of a binary file, a half-written UTF-8 character |
| 26 | * at the edge of a read, and control sequences whose meaning is their exact |
| 27 | * bytes. One mangled byte draws the rest of the screen wrong, and a lossy |
| 28 | * conversion corrupts precisely the case a terminal exists to handle. |
| 29 | * |
| 30 | * ── A hole is shown, not stitched ─────────────────────────────────── |
| 31 | * |
| 32 | * `output` carries a monotonic `seq`, and a step that is not +1 means a chunk is |
| 33 | * missing. hand.js writes a marker INTO the stream at that point, because its |
| 34 | * stream is text a model reads and the marker is a sentence. This file must not: |
| 35 | * bytes written into a terminal stream are drawn, so a marker would be an escape |
| 36 | * sequence's worth of damage on top of the loss. So the gap is surfaced BESIDE |
| 37 | * the stream — `onGap` — and the bytes still go through. A terminal stitched |
| 38 | * silently over a missing chunk draws a screen that never existed, which is the |
| 39 | * one outcome worth going to any length to avoid. |
| 40 | * |
| 41 | * ── One link, shared ──────────────────────────────────────────────── |
| 42 | * |
| 43 | * hand.js holds ONE port to the extension, opened lazily on the first thing that |
| 44 | * needs a hand, and multiplexes runs by the `id` the wire already carries. A |
| 45 | * terminal session travels on that same link and is told apart by the same id. |
| 46 | * Opening a second port would start a second host process, ask a second approval |
| 47 | * question, and give the user two hands where they granted one. |
| 48 | * |
| 49 | * So this file owns no transport at all. It needs exactly two things from |
| 50 | * hand.js, and nothing else: |
| 51 | * |
| 52 | * DaimondHand.send(msg) -> Promise<void> |
| 53 | * Post one wire message on the one link, opening and greeting it |
| 54 | * first if need be. Rejects with the sentence the model reads, |
| 55 | * verbatim: not installed, declined, stopped part-way. |
| 56 | * |
| 57 | * DaimondHand.subscribe(id, fn) -> unsubscribe() |
| 58 | * Every message the hand sends carrying that `id`, in arrival |
| 59 | * order. Plus `{t:'__gone', message, met}` when the link dies, |
| 60 | * where `message` is the sentence hand.js already writes and `met` |
| 61 | * says whether a hand ever answered in this page. |
| 62 | * |
| 63 | * ── The fence is not this file's to invent ────────────────────────── |
| 64 | * |
| 65 | * A terminal session runs a real program on the user's machine, so it goes |
| 66 | * through the same fence and the same grant as `Tool::Run`: `fence_spec` in |
| 67 | * src/tools.rs computes the compartment, the extension vets it, and the hand |
| 68 | * enforces it. This file composes no fence and relaxes none. It refuses an |
| 69 | * `open` that arrives without one — not as a security boundary, which it is not |
| 70 | * placed to be, but because a caller that forgot the fence has a bug, and the |
| 71 | * sentence saying so is more use than a refusal from two layers down. |
| 72 | */ |
| 73 | (function () { |
| 74 | 'use strict'; |
| 75 | |
| 76 | /// What a caller reads when this page has no hand relay at all. Distinct |
| 77 | /// from "no hand is paired": the relay is part of the app, so its absence |
| 78 | /// means the page is broken rather than the machine unequipped. |
| 79 | var NO_RELAY = 'This page has no machine hand relay loaded, so no terminal can be opened. ' |
| 80 | + 'That is a fault in the app rather than anything about the user\'s machine: ' |
| 81 | + 'js/hand.js has not been loaded. Nothing else is affected.'; |
| 82 | |
| 83 | /// What a caller reads when hand.js is present but predates terminals. |
| 84 | /// |
| 85 | /// Kept apart from `NO_RELAY` because the instruction differs: one is a page |
| 86 | /// missing a file, the other a page whose relay cannot carry these messages, |
| 87 | /// and telling a user their hand is broken when the page is old would send |
| 88 | /// them to reinstall software that is working. |
| 89 | var NO_CARRY = 'The machine hand relay in this page cannot carry terminal messages. ' |
| 90 | + 'The hand itself may be perfectly healthy — it is this page that is older than ' |
| 91 | + 'the terminal. Reload the app, and if it persists the app needs updating. ' |
| 92 | + 'Commands can still be run; only the interactive terminal is unavailable.'; |
| 93 | |
| 94 | /// The longest the page waits for `opened` after asking for a terminal. |
| 95 | /// |
| 96 | /// Long, because a human sits inside it: the first thing on this link puts |
| 97 | /// the approval window on the user's screen, and hand.js holds the request |
| 98 | /// in order until it is answered. Bounded all the same, because a question |
| 99 | /// nobody answers must still end as a refusal rather than as a terminal that |
| 100 | /// never draws anything and never says why. |
| 101 | var OPEN_WAIT = 60000; |
| 102 | |
| 103 | /// How much output is held for a session nobody has subscribed to yet. |
| 104 | /// |
| 105 | /// A program can write its first screen before the caller has attached a |
| 106 | /// renderer, and a terminal that misses its own first screen is broken. What |
| 107 | /// will not fit is dropped from the OLDEST end and counted, and the count is |
| 108 | /// reported as a gap on attachment — the same rule as everywhere else in |
| 109 | /// this file: what is lost is said, never smoothed over. |
| 110 | var BUFFER_MAX = 256 * 1024; |
| 111 | |
| 112 | /// The largest terminal this page will ask for. A size is two `u16`s on the |
| 113 | /// wire and a number outside that is not a big terminal, it is a bug on its |
| 114 | /// way to becoming a frame the hand cannot read. |
| 115 | var CELLS_MAX = 65535; |
| 116 | |
| 117 | /// Sessions believed to be open, by id. |
| 118 | var live = {}; |
| 119 | |
| 120 | /// Serial for minted ids, so two terminals in one page never collide. |
| 121 | var serial = 0; |
| 122 | |
| 123 | // ── The link ──────────────────────────────────────────────────── |
| 124 | |
| 125 | /// The hand relay, or null when this page has none. |
| 126 | function hand() { |
| 127 | return (window.DaimondHand && typeof window.DaimondHand === 'object') |
| 128 | ? window.DaimondHand : null; |
| 129 | } |
| 130 | |
| 131 | /// Whether the relay in this page can carry terminal messages at all. |
| 132 | /// |
| 133 | /// Feature-detected rather than assumed, so a page whose hand.js predates |
| 134 | /// terminals refuses with a sentence instead of throwing on a missing |
| 135 | /// method — the difference between a user who knows to reload and one |
| 136 | /// watching a blank panel. |
| 137 | function carries() { |
| 138 | var h = hand(); |
| 139 | return !!(h && typeof h.send === 'function' && typeof h.subscribe === 'function'); |
| 140 | } |
| 141 | |
| 142 | /// Why this page cannot carry a terminal, or '' when it can. |
| 143 | function whyNot() { |
| 144 | if (!hand()) return NO_RELAY; |
| 145 | if (!carries()) return NO_CARRY; |
| 146 | return ''; |
| 147 | } |
| 148 | |
| 149 | // ── Bytes ─────────────────────────────────────────────────────── |
| 150 | |
| 151 | /// Base64 to the bytes it stands for. |
| 152 | /// |
| 153 | /// `atob` yields a binary string — one UTF-16 unit per byte, each below |
| 154 | /// 256 — which is masked back down to bytes. Going through a string is not |
| 155 | /// elegant and is the only decoder a page has without a dependency; the mask |
| 156 | /// is what makes it exact rather than nearly right. |
| 157 | function bytesOf(b64) { |
| 158 | var s = atob(b64); |
| 159 | var out = new Uint8Array(s.length); |
| 160 | for (var i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff; |
| 161 | return out; |
| 162 | } |
| 163 | |
| 164 | /// Bytes to the base64 the wire carries them as. |
| 165 | /// |
| 166 | /// Chunked, because `String.fromCharCode.apply` on a large array overflows |
| 167 | /// the argument stack — a paste of a long file is exactly the case that |
| 168 | /// finds it. |
| 169 | function b64Of(u8) { |
| 170 | var s = ''; |
| 171 | for (var i = 0; i < u8.length; i += 0x8000) { |
| 172 | s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)); |
| 173 | } |
| 174 | return btoa(s); |
| 175 | } |
| 176 | |
| 177 | /// Whatever a caller typed, as bytes. |
| 178 | /// |
| 179 | /// A string is taken as text and encoded UTF-8, which is what a keyboard |
| 180 | /// produces; anything array-like is taken as the bytes it already is. |
| 181 | function asBytes(data) { |
| 182 | if (data == null) return new Uint8Array(0); |
| 183 | if (typeof data === 'string') return new TextEncoder().encode(data); |
| 184 | if (data instanceof Uint8Array) return data; |
| 185 | if (data instanceof ArrayBuffer) return new Uint8Array(data); |
| 186 | if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); |
| 187 | if (Array.isArray(data)) return new Uint8Array(data); |
| 188 | return new TextEncoder().encode(String(data)); |
| 189 | } |
| 190 | |
| 191 | // ── Sessions ──────────────────────────────────────────────────── |
| 192 | |
| 193 | /// A fresh session record. |
| 194 | function session(id) { |
| 195 | return { |
| 196 | id: id, |
| 197 | pid: 0, |
| 198 | seq: null, // set by the FIRST output; where the hand starts counting is its business |
| 199 | subs: null, // the handlers, once someone attaches |
| 200 | off: null, // unsubscribe from the link |
| 201 | buf: [], // output held for a subscriber that has not attached |
| 202 | bufLen: 0, |
| 203 | dropped: 0, // bytes the buffer could not hold |
| 204 | gaps: 0, // holes seen, for a caller that wants to say so on screen |
| 205 | done: false, |
| 206 | timer: null, |
| 207 | resolve: null, |
| 208 | reject: null, |
| 209 | }; |
| 210 | } |
| 211 | |
| 212 | /// Call one handler without letting it take the relay down with it. |
| 213 | /// |
| 214 | /// A renderer that throws on one frame must not stop the next one arriving: |
| 215 | /// the bytes are the machine's, and losing them because the drawing code has |
| 216 | /// a bug turns a visual fault into a lost session. |
| 217 | function fire(s, name, arg) { |
| 218 | if (!s.subs || typeof s.subs[name] !== 'function') return; |
| 219 | try { s.subs[name](arg); } catch (e) { /* the renderer's problem, not the link's */ } |
| 220 | } |
| 221 | |
| 222 | /// Give a subscriber the bytes, or hold them until there is one. |
| 223 | function deliver(s, u8) { |
| 224 | if (!u8.length) return; |
| 225 | if (s.subs) { fire(s, 'onOutput', u8); return; } |
| 226 | s.buf.push(u8); |
| 227 | s.bufLen += u8.length; |
| 228 | while (s.bufLen > BUFFER_MAX && s.buf.length > 1) { |
| 229 | var old = s.buf.shift(); |
| 230 | s.bufLen -= old.length; |
| 231 | s.dropped += old.length; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /// Hand a newly attached subscriber everything that arrived before it. |
| 236 | function flush(s) { |
| 237 | var held = s.buf; |
| 238 | var lost = s.dropped; |
| 239 | s.buf = []; |
| 240 | s.bufLen = 0; |
| 241 | s.dropped = 0; |
| 242 | if (lost) { |
| 243 | s.gaps++; |
| 244 | fire(s, 'onGap', { |
| 245 | dropped: lost, |
| 246 | reason: 'Output arrived before anything was drawing it, and more of it than the page ' |
| 247 | + 'would hold, so the oldest ' + lost + ' bytes were dropped. The screen below ' |
| 248 | + 'starts part-way through.', |
| 249 | }); |
| 250 | } |
| 251 | for (var i = 0; i < held.length; i++) fire(s, 'onOutput', held[i]); |
| 252 | } |
| 253 | |
| 254 | /// Finish a session once, whichever way it finished. |
| 255 | /// |
| 256 | /// `how` is the ending as a caller reads it: an exit status where the |
| 257 | /// program had one, and a sentence where the link died instead. |
| 258 | function settle(s, how) { |
| 259 | if (s.done) return; |
| 260 | s.done = true; |
| 261 | clearTimeout(s.timer); |
| 262 | delete live[s.id]; |
| 263 | // A caller still waiting on the opening is owed the sentence rather than |
| 264 | // a promise that never settles: `reject` is cleared the moment `opened` |
| 265 | // arrives, so its presence here means the terminal never opened at all. |
| 266 | if (s.reject) { |
| 267 | var rej = s.reject; |
| 268 | s.resolve = null; |
| 269 | s.reject = null; |
| 270 | rej(new Error(how.refusal || how.reason || 'The terminal closed before it opened.')); |
| 271 | } |
| 272 | fire(s, 'onClosed', how); |
| 273 | if (s.off) { try { s.off(); } catch (e) { /* already gone */ } s.off = null; } |
| 274 | } |
| 275 | |
| 276 | /// Accumulate one `output`, in order, and pass it on. |
| 277 | /// |
| 278 | /// The FIRST output sets the baseline — where the hand starts counting is |
| 279 | /// the hand's business, and hand.js's `absorb` and the extension's own check |
| 280 | /// both say the same. Assuming zero would open every session with a hole it |
| 281 | /// did not have, which is the same failure as hiding one: the marker stops |
| 282 | /// meaning anything. |
| 283 | function absorb(s, msg) { |
| 284 | var want = s.seq; |
| 285 | if (want !== null && msg.seq !== want) { |
| 286 | s.gaps++; |
| 287 | fire(s, 'onGap', { |
| 288 | expected: want, |
| 289 | got: msg.seq, |
| 290 | missing: msg.seq - want, |
| 291 | backwards: msg.seq < want, |
| 292 | reason: msg.seq < want |
| 293 | ? 'The terminal\'s output went backwards, from ' + want + ' to ' + msg.seq |
| 294 | + '. What is drawn after this point is not what the program wrote.' |
| 295 | : 'The terminal is missing ' + (msg.seq - want) + ' chunk(s) of output, ' |
| 296 | + 'between sequence ' + want + ' and ' + msg.seq + '. The screen below ' |
| 297 | + 'has a hole in it and cannot be trusted as a transcript.', |
| 298 | }); |
| 299 | } |
| 300 | s.seq = msg.seq + 1; |
| 301 | var u8; |
| 302 | try { u8 = bytesOf(String(msg.data == null ? '' : msg.data)); } |
| 303 | catch (e) { |
| 304 | fire(s, 'onError', 'The machine hand sent a chunk of terminal output that is not ' |
| 305 | + 'base64, so those bytes are lost. The rest of the session carries on.'); |
| 306 | return; |
| 307 | } |
| 308 | deliver(s, u8); |
| 309 | } |
| 310 | |
| 311 | /// One message about a session, routed. |
| 312 | /// |
| 313 | /// Only a RECOGNISED type does anything, exactly as in hand.js: a host |
| 314 | /// sending something the page does not understand is not evidence that a |
| 315 | /// terminal is alive, and treating it as such is how a promise is held open |
| 316 | /// for ever. |
| 317 | function fromHand(s, msg) { |
| 318 | if (!msg || typeof msg.t !== 'string') return; |
| 319 | |
| 320 | if (msg.t === 'opened') { |
| 321 | s.pid = Number(msg.pid) || 0; |
| 322 | clearTimeout(s.timer); |
| 323 | if (s.resolve) { |
| 324 | var res = s.resolve; |
| 325 | s.resolve = null; |
| 326 | s.reject = null; |
| 327 | res({ id: s.id, pid: s.pid }); |
| 328 | } |
| 329 | return; |
| 330 | } |
| 331 | if (msg.t === 'output') { absorb(s, msg); return; } |
| 332 | if (msg.t === 'closed') { |
| 333 | settle(s, { |
| 334 | id: s.id, |
| 335 | exit: typeof msg.exit === 'number' ? msg.exit : -1, |
| 336 | killed: !!msg.killed, |
| 337 | gaps: s.gaps, |
| 338 | }); |
| 339 | return; |
| 340 | } |
| 341 | if (msg.t === 'refused') { |
| 342 | // Whole sentences, written for a person or a model to act on. Passed |
| 343 | // through untouched: wrapping one loses the only instruction it gives. |
| 344 | settle(s, { id: s.id, exit: -1, killed: false, gaps: s.gaps, refusal: msg.reason || '' }); |
| 345 | return; |
| 346 | } |
| 347 | if (msg.t === 'error') { |
| 348 | // An error ABOUT a session is a note, not an ending — the same rule |
| 349 | // hand.js follows. The extension reports a sequence gap this way and |
| 350 | // then carries on sending output; treating it as a settlement throws |
| 351 | // away a session that was going to keep working. |
| 352 | fire(s, 'onError', msg.message || 'The machine hand reported a problem with this terminal.'); |
| 353 | return; |
| 354 | } |
| 355 | if (msg.t === '__gone') { |
| 356 | // The link died. `met` is hand.js's own record of whether a hand ever |
| 357 | // answered in this page, and it is the whole difference between "you |
| 358 | // have not installed it" and "it stopped" — two different instructions |
| 359 | // to a user, and telling someone to install what they already have |
| 360 | // wastes their afternoon. |
| 361 | settle(s, { |
| 362 | id: s.id, |
| 363 | exit: -1, |
| 364 | killed: true, |
| 365 | gaps: s.gaps, |
| 366 | stopped: !!msg.met, |
| 367 | absent: !msg.met, |
| 368 | reason: msg.message || NO_RELAY, |
| 369 | }); |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // ── Opening one ───────────────────────────────────────────────── |
| 374 | |
| 375 | /// A caller-supplied id, checked, or a fresh one. |
| 376 | /// |
| 377 | /// The id is echoed on every message about the session, so an unbounded or |
| 378 | /// unprintable one is a frame the hand cannot send. The extension enforces |
| 379 | /// the same limits on the way past; this refuses earlier, where the caller |
| 380 | /// can still be told which of its own values was wrong. |
| 381 | function idFor(spec) { |
| 382 | var id = spec && typeof spec.id === 'string' ? spec.id : ''; |
| 383 | if (!id) { |
| 384 | serial++; |
| 385 | return 'pty-' + serial + '-' + Math.random().toString(36).slice(2, 8); |
| 386 | } |
| 387 | return id; |
| 388 | } |
| 389 | |
| 390 | /// What is wrong with an `open`, in a whole sentence, or '' when nothing is. |
| 391 | /// |
| 392 | /// Deliberately short. The compartment is checked by the extension and |
| 393 | /// ENFORCED by the hand, which knows what it granted; nothing here pretends |
| 394 | /// otherwise. What it does is catch a caller that forgot the fence, because |
| 395 | /// a refusal naming `fence_spec` is more use than one from two layers down |
| 396 | /// that can only say the fence was missing. |
| 397 | function wrongOpen(spec) { |
| 398 | if (!spec || typeof spec !== 'object') { |
| 399 | return 'A terminal needs a request saying what to run: {argv, cwd, env, size, fence}.'; |
| 400 | } |
| 401 | if (!Array.isArray(spec.argv) || !spec.argv.length |
| 402 | || !spec.argv.every(function (a) { return typeof a === 'string'; })) { |
| 403 | return 'A terminal needs argv: the program and its arguments, as an array of strings. ' |
| 404 | + 'A shell is a perfectly ordinary thing to put in argv[0] — it is a shell STRING ' |
| 405 | + 'that has no meaning here, because there is nothing to interpret one.'; |
| 406 | } |
| 407 | if (typeof spec.cwd !== 'string' || spec.cwd.charAt(0) !== '/') { |
| 408 | return 'A terminal needs cwd: an absolute working directory inside the fence. ' |
| 409 | + 'The hand does not guess what a relative path is relative to.'; |
| 410 | } |
| 411 | if (!spec.fence || typeof spec.fence !== 'object' || Array.isArray(spec.fence)) { |
| 412 | return 'A terminal needs a fence saying what the session may touch: {rw, ro, deny, net}. ' |
| 413 | + 'It is composed by fence_spec in src/tools.rs from the Diamond\'s bounds and the ' |
| 414 | + 'folder the user granted, exactly as it is for a command — this relay does not ' |
| 415 | + 'compose one, and a session with no compartment is not opened.'; |
| 416 | } |
| 417 | var rw = Array.isArray(spec.fence.rw) ? spec.fence.rw : []; |
| 418 | var ro = Array.isArray(spec.fence.ro) ? spec.fence.ro : []; |
| 419 | if (!rw.length && !ro.length) { |
| 420 | return 'That fence names no root at all, so the session could not read the directory it ' |
| 421 | + 'would start in. Say what it may work under.'; |
| 422 | } |
| 423 | return ''; |
| 424 | } |
| 425 | |
| 426 | /// A size the wire can carry, from whatever the caller offered. |
| 427 | function sizeOf(size) { |
| 428 | var cols = Math.floor(Number((size && size.cols) || 80)); |
| 429 | var rows = Math.floor(Number((size && size.rows) || 24)); |
| 430 | if (!(cols > 0)) cols = 80; |
| 431 | if (!(rows > 0)) rows = 24; |
| 432 | return { cols: Math.min(cols, CELLS_MAX), rows: Math.min(rows, CELLS_MAX) }; |
| 433 | } |
| 434 | |
| 435 | /// Open a terminal and attach a program to it. |
| 436 | /// |
| 437 | /// `spec` is the wire's own `open` request, composed by the caller that owns |
| 438 | /// the fence — the Rust side, via `fence_spec`. It is passed through |
| 439 | /// unchanged but for an id and a size, so there is ONE place a request is |
| 440 | /// composed and it is the one that holds the compartment. |
| 441 | /// |
| 442 | /// `subs` is optional and may also be attached later with `subscribe`; |
| 443 | /// passing it here is the safe order, because a program can write its first |
| 444 | /// screen before this promise resolves. |
| 445 | /// |
| 446 | /// # Returns |
| 447 | /// A promise resolving to `{id, pid}` when the hand says `opened`, and |
| 448 | /// rejecting with the refusal verbatim when it will not. |
| 449 | function open(spec, subs) { |
| 450 | if (typeof spec === 'string') { |
| 451 | try { spec = JSON.parse(spec); } |
| 452 | catch (e) { return Promise.reject(new Error('The terminal request could not be read: ' + e.message)); } |
| 453 | } |
| 454 | var no = whyNot(); |
| 455 | if (no) return Promise.reject(new Error(no)); |
| 456 | var bad = wrongOpen(spec); |
| 457 | if (bad) return Promise.reject(new Error(bad)); |
| 458 | |
| 459 | var id = idFor(spec); |
| 460 | var s = session(id); |
| 461 | if (subs) s.subs = subs; |
| 462 | live[id] = s; |
| 463 | |
| 464 | // EVERY FIELD THE COMPOSER SENT, and not a list of the ones this file |
| 465 | // happened to know about. |
| 466 | // |
| 467 | // It WAS such a list until 2026-08-24, and the list was one field out of |
| 468 | // date. `pty_request` grew `toolkits` when a Diamond's granted toolchain |
| 469 | // began travelling beside the fence — the hand cannot check a fence naming |
| 470 | // `~/.cargo/registry` against the granted root unless it is TOLD which |
| 471 | // toolchain was granted — and this end went on sending the same six |
| 472 | // fields. So a session in a Diamond granted git arrived at the extension |
| 473 | // with `~/.gitconfig` in its fence and no toolchain named, was refused by |
| 474 | // the extension's own correct rule, and the owner could not open a |
| 475 | // terminal at all. The two ends had not disagreed about the fence; one of |
| 476 | // them had simply stopped copying part of the request. |
| 477 | // |
| 478 | // The compartment is composed in ONE place, in Rust, and `wrongOpen` above |
| 479 | // says so in as many words. A relay that re-lists the fields is a second |
| 480 | // composer holding an older idea of what a request is, so this one |
| 481 | // re-lists nothing: what arrived is forwarded whole, and only what this |
| 482 | // end OWNS is set over the top of it. The id is this end's because only it |
| 483 | // knows which sessions this page already has open; the size is normalised |
| 484 | // because the wire carries two cell counts and a caller may hand over |
| 485 | // anything; `env` and `argv` are pinned to the shapes the wire requires. |
| 486 | // The extension checks what arrives and the hand enforces it, so a field |
| 487 | // this end does not understand is not this end's to drop. |
| 488 | var msg = {}; |
| 489 | for (var k in spec) { |
| 490 | if (Object.prototype.hasOwnProperty.call(spec, k)) msg[k] = spec[k]; |
| 491 | } |
| 492 | msg.t = 'open'; |
| 493 | msg.id = id; |
| 494 | msg.argv = spec.argv.slice(); |
| 495 | msg.env = Array.isArray(spec.env) ? spec.env : []; |
| 496 | msg.size = sizeOf(spec.size); |
| 497 | |
| 498 | return new Promise(function (resolve, reject) { |
| 499 | s.resolve = resolve; |
| 500 | s.reject = reject; |
| 501 | s.off = hand().subscribe(id, function (m) { fromHand(s, m); }); |
| 502 | s.timer = setTimeout(function () { |
| 503 | settle(s, { |
| 504 | id: id, exit: -1, killed: false, gaps: s.gaps, |
| 505 | refusal: 'Daimond asked for a terminal and the machine hand did not open one. The ' |
| 506 | + 'approval window may still be waiting — the Daimond Hands toolbar icon carries ' |
| 507 | + 'the question until it is answered. Answer it and try again.', |
| 508 | }); |
| 509 | }, OPEN_WAIT); |
| 510 | hand().send(msg).catch(function (e) { |
| 511 | // hand.js's rejection is already a whole sentence about a hand |
| 512 | // that is missing, declined or stopped. Verbatim, or the user is |
| 513 | // told to fix the wrong thing. |
| 514 | settle(s, { id: id, exit: -1, killed: false, gaps: s.gaps, refusal: (e && e.message) || NO_RELAY }); |
| 515 | }); |
| 516 | }); |
| 517 | } |
| 518 | |
| 519 | /// Attach a renderer to a session, and receive everything it has already |
| 520 | /// said. |
| 521 | /// |
| 522 | /// `subs` is `{onOutput, onGap, onClosed, onError}`, all optional: |
| 523 | /// |
| 524 | /// onOutput(Uint8Array) exactly the bytes the program wrote |
| 525 | /// onGap({expected, got, missing, backwards, dropped, reason}) |
| 526 | /// output is missing; what follows is not continuous |
| 527 | /// onClosed({exit, killed, gaps, stopped, absent, reason, refusal}) |
| 528 | /// the session is over, one way or another |
| 529 | /// onError(sentence) a note about the session that did not end it |
| 530 | /// |
| 531 | /// # Returns |
| 532 | /// A function that detaches. Detaching does not close the session; use |
| 533 | /// `close` for that. |
| 534 | function subscribe(id, subs) { |
| 535 | var s = live[id]; |
| 536 | if (!s) return function () {}; |
| 537 | s.subs = subs || null; |
| 538 | if (s.subs) flush(s); |
| 539 | return function () { s.subs = null; }; |
| 540 | } |
| 541 | |
| 542 | /// Send keystrokes to a terminal. |
| 543 | /// |
| 544 | /// Raw, and not a line: a terminal is a byte stream, and `Ctrl-C`, an arrow |
| 545 | /// key and a bracketed paste are all just bytes the program is entitled to |
| 546 | /// see as they were typed. A string is encoded UTF-8; anything array-like is |
| 547 | /// sent as the bytes it already is. |
| 548 | function input(id, data) { |
| 549 | var no = whyNot(); |
| 550 | if (no) return Promise.reject(new Error(no)); |
| 551 | if (!live[id]) { |
| 552 | return Promise.reject(new Error('There is no terminal "' + id + '" in this page to type into. ' |
| 553 | + 'It has closed, or it was never opened here.')); |
| 554 | } |
| 555 | var u8 = asBytes(data); |
| 556 | if (!u8.length) return Promise.resolve(); |
| 557 | return hand().send({ t: 'input', id: id, data: b64Of(u8) }); |
| 558 | } |
| 559 | |
| 560 | /// Tell the kernel the window changed size, which tells the program. |
| 561 | /// |
| 562 | /// A program asks the kernel how big its terminal is, not the page, so it |
| 563 | /// has to be told at the pty and told again on every change — a `less` that |
| 564 | /// thinks it has 24 rows on an 80-row screen is the visible symptom of |
| 565 | /// forgetting the second half. |
| 566 | function resize(id, cols, rows) { |
| 567 | var no = whyNot(); |
| 568 | if (no) return Promise.reject(new Error(no)); |
| 569 | if (!live[id]) { |
| 570 | return Promise.reject(new Error('There is no terminal "' + id + '" in this page to resize.')); |
| 571 | } |
| 572 | return hand().send({ t: 'resize', id: id, size: sizeOf({ cols: cols, rows: rows }) }); |
| 573 | } |
| 574 | |
| 575 | /// Ask a terminal's program to stop. |
| 576 | /// |
| 577 | /// **There is no `close` on the wire, and that is deliberate.** A session |
| 578 | /// ends when the program does, and the way to end a program is to signal it |
| 579 | /// — the same `Req::Signal` any other run is ended with, carrying this |
| 580 | /// session's id. So this asks, and the authoritative ending remains the |
| 581 | /// `closed` message the hand sends when the program has actually gone. |
| 582 | /// |
| 583 | /// # Arguments |
| 584 | /// * `id` - The session. |
| 585 | /// * `sig` - `'term'` to ask, `'kill'` to insist, `'int'` to interrupt as |
| 586 | /// `Ctrl-C` would. Asking is the default: a shell given `SIGTERM` writes |
| 587 | /// out its history, and one given `SIGKILL` does not. |
| 588 | function close(id, sig) { |
| 589 | var no = whyNot(); |
| 590 | if (no) return Promise.reject(new Error(no)); |
| 591 | if (!live[id]) return Promise.resolve(); |
| 592 | var which = (sig === 'kill' || sig === 'int') ? sig : 'term'; |
| 593 | return hand().send({ t: 'signal', id: id, sig: which }); |
| 594 | } |
| 595 | |
| 596 | /// Forget a session locally, without asking the machine anything. |
| 597 | /// |
| 598 | /// For a caller tearing down its own view: the program is still running and |
| 599 | /// the hand still owns it. Ending the LINK is what ends the program, and |
| 600 | /// hand.js does that when the page goes away. |
| 601 | function forget(id) { |
| 602 | var s = live[id]; |
| 603 | if (!s) return; |
| 604 | settle(s, { |
| 605 | id: id, exit: -1, killed: false, gaps: s.gaps, |
| 606 | reason: 'The page let go of this terminal. The program is the hand\'s until the link closes.', |
| 607 | }); |
| 608 | } |
| 609 | |
| 610 | /// What can be said about terminals here, without opening anything. |
| 611 | /// |
| 612 | /// It never rejects: a caller asking what is attached is owed an answer, and |
| 613 | /// the sentence explaining a "no" is otherwise lost. `carries` is about THIS |
| 614 | /// PAGE — whether its relay can carry these messages at all — and is a |
| 615 | /// different question from whether the machine's hand can allocate a pty, |
| 616 | /// which the hand answers in its own `caps` and which is read from there. |
| 617 | function status() { |
| 618 | var no = whyNot(); |
| 619 | var mine = { carries: carries(), sessions: Object.keys(live).length }; |
| 620 | if (no) { |
| 621 | return Promise.resolve(JSON.stringify(Object.assign({ |
| 622 | paired: false, transport: 'none', caps: [], reason: no, |
| 623 | }, mine))); |
| 624 | } |
| 625 | return hand().status().then(function (raw) { |
| 626 | var j; |
| 627 | try { j = JSON.parse(raw); } catch (e) { j = { paired: false, caps: [], reason: String(raw) }; } |
| 628 | return JSON.stringify(Object.assign(j, mine)); |
| 629 | }, function (e) { |
| 630 | return JSON.stringify(Object.assign({ |
| 631 | paired: false, transport: 'none', caps: [], reason: (e && e.message) || no || NO_RELAY, |
| 632 | }, mine)); |
| 633 | }); |
| 634 | } |
| 635 | |
| 636 | /// The sessions this page believes are open. |
| 637 | function sessions() { |
| 638 | return Object.keys(live); |
| 639 | } |
| 640 | |
| 641 | window.DaimondPty = { |
| 642 | open: open, |
| 643 | subscribe: subscribe, |
| 644 | input: input, |
| 645 | resize: resize, |
| 646 | close: close, |
| 647 | forget: forget, |
| 648 | status: status, |
| 649 | sessions: sessions, |
| 650 | /// Test only. The waits are tens of seconds by design, and a test cannot |
| 651 | /// spend a minute proving a terminal never opened. Same-origin callers |
| 652 | /// only, and the worst one can do with it is make its own sessions give |
| 653 | /// up sooner. |
| 654 | _setWaitsForTest: function (o) { |
| 655 | o = (typeof o === 'number') ? { open: o } : (o || {}); |
| 656 | if (o.open > 0) OPEN_WAIT = o.open; |
| 657 | if (o.buffer > 0) BUFFER_MAX = o.buffer; |
| 658 | return { open: OPEN_WAIT, buffer: BUFFER_MAX }; |
| 659 | }, |
| 660 | }; |
| 661 | })(); |