Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/ext/hand.js

77.7 KiB, 1 run

created by r2519314175:879, 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// Daimond Hands -- the machine hand's relay.
2//
3// A web page cannot create a process. There is no flag and no future API, so
4// the capability has to live in a program outside the page, and the only
5// question worth arguing about is who may talk to that program. The answer is
6// this file: Chrome connects the native messaging host to ONE extension, and
7// this extension is connectable from the Daimond origins alone. There is no
8// port to find and no secret to steal, because the browser is the doorman. A
9// loopback daemon would be reachable by any page the user visits, and its whole
10// defence would be one pasted secret. That reasoning is settled; see
11// `hand/src/lib.rs`.
12//
13// So this file is a relay and almost nothing else. It carries wire messages
14// between two ports:
15//
16// the PAGE port -- chrome.runtime.connect(extId, {name:'daimond-hand'}),
17// which externally_connectable already restricts to the
18// Daimond origins, and which is checked again here;
19// the HOST port -- chrome.runtime.connectNative('com.oxedyne.daimond.hand'),
20// a long-lived port rather than sendNativeMessage,
21// because output STREAMS and a request/response call
22// cannot carry a stream.
23//
24// One host process per page port. Chrome starts a fresh binary for every
25// connectNative, and pairing them one to one means the handshake is per
26// connection exactly as the wire describes it, a page that goes away takes its
27// own host with it and nobody else's, and a host that dies kills only the runs
28// that belonged to it.
29//
30// ONE QUALIFICATION, AND IT IS THE RELOAD. A page that goes away does not take
31// its host with it AT ONCE: the pair is parked for thirty seconds and the same
32// tab, reloaded, adopts it. What arrives meanwhile is held and handed over on
33// re-attach; what the grace runs out on is stopped, named, and reported to the
34// next page from that tab. See "The reload grace" below.
35//
36// Three things this relay owes the page, which are the whole of its work.
37//
38// ORDER AND ATTRIBUTION. Every chunk carries an id, a stream and a monotonic
39// per-stream seq. The relay forwards one message for one message: it never
40// batches, never joins two chunks into a bigger one, and never holds one back
41// to send it beside its neighbour. It also WATCHES the seq, and a step that is
42// not +1 is announced as a gap before the chunk that revealed it. A gap that is
43// hidden is output the reader believes is complete.
44//
45// THE 1 MB CAP. Chrome caps a host->extension message at 1 MB and drops the
46// connection without ceremony when one exceeds it -- no error to the host, no
47// event but a disconnect. The hand chunks below that (wire::CHUNK_MAX), so this
48// should not happen; when it does, the disconnect is indistinguishable from a
49// crash and would otherwise leave the page waiting for an `ended` that is never
50// coming. So every disconnect closes out every run in flight and says what it
51// might have been, and says it in the sentence the model reads.
52//
53// THE FIRST RUN. The commonest failure by far is that the host is not
54// installed, and Chrome reports it as "Specified native messaging host not
55// found." An error that repeats Chrome's sentence tells the user nothing they
56// can act on. So that one case gets a sentence naming the host, the install
57// script and the one thing to do next.
58//
59// Two audiences, two languages -- the same rule the broker follows. What the
60// DAIMON reads, which is every `t:'error'` and `t:'refused'` sentence crossing
61// the boundary, stays English: it is a protocol the model acts on. What the
62// USER reads, which is the grant window and the toolbar, is translated.
63
64'use strict';
65
66(() => {
67
68 const I = globalThis.DaimondExtI18n;
69 const T = (...a) => I.t(...a);
70
71 // ------------------------------------------------------------------
72 // Constants
73 // ------------------------------------------------------------------
74
75 /// The native messaging host's name, which is also the manifest's file name
76 /// in each browser's NativeMessagingHosts directory. `hand/install/` writes
77 /// it; nothing else in the product knows this string.
78 const HOST_NAME = 'com.oxedyne.daimond.hand';
79
80 /// The port name a page must connect with. A name rather than an empty
81 /// connect, so a later feature can open a second kind of port on the same
82 /// boundary without either one guessing which it is.
83 const PORT_NAME = 'daimond-hand';
84
85 /// Where the user's approvals are kept, one entry per origin. `local`, not
86 /// `session`: a grant that evaporated when the browser restarted would be
87 /// asked for again every morning, and a question asked that often stops
88 /// being read.
89 ///
90 /// The value is `{ '<origin>': { at, caps } }`. It was one boolean for the
91 /// whole browser until 2026-08-02, and a reviewer showed what that costs:
92 /// allowed once from `http://127.0.0.1:8777`, the hand was then reachable
93 /// from `http://localhost:8777` with no window shown at all. A site grant
94 /// next door is a real per-origin pattern that Chrome itself enforces, and
95 /// this is the strongest thing in the product -- it cannot be the laxest.
96 const GRANT_KEY = 'handGrants';
97
98 /// What the popup lists these grants under, and the head of what its Revoke
99 /// button sends back. Not a match pattern, because the thing granted is not
100 /// an origin -- it is the machine, to one origin -- so it is deliberately
101 /// unlike one. The whole pattern is `machine-hand:<origin>`.
102 const PATTERN = 'machine-hand';
103
104 /// The separator between that head and the origin it is about.
105 const PATTERN_SEP = ':';
106
107 /// The wire protocol version this relay was written against. It is not
108 /// interpreted here: the page announces its own in `hello` and the hand
109 /// answers with its own, and the two settle it between them. It is held
110 /// only so a relay that has drifted can be recognised in a report.
111 const PROTO = 2;
112
113 /// Chrome's cap on a message FROM the host, and the reason a disconnect is
114 /// ambiguous. Mirrors `wire::FRAME_MAX`.
115 const FROM_HOST_MAX = 1000000;
116
117 /// Chrome's cap on a message TO the host is 64 MB. A request over it kills
118 /// the connection the same silent way, so an oversized `stdin` is refused
119 /// here, with a sentence, rather than being sent and losing everything in
120 /// flight. Set below the cap so the envelope cannot push it over.
121 const TO_HOST_MAX = 60 * 1024 * 1024;
122
123 /// While a command is running the worker must stay awake, and an MV3 worker
124 /// is evicted after five minutes of quiet. A connected port resets that
125 /// timer, but a build that prints nothing for six minutes is quiet by any
126 /// measure. This is the plain keep-alive: a trivial API call on a timer,
127 /// only while something is actually running.
128 const AWAKE_MS = 20000;
129
130 /// How long the hand is given to say what it can enforce, before the user is
131 /// asked without it. The exchange is one message each way over a pipe, so
132 /// this is generous; it exists so a wedged host cannot leave the question
133 /// unasked for ever.
134 const CAPS_MS = 5000;
135
136 // -- The reload grace -------------------------------------------------
137 //
138 // A page that goes away used to take the machine hand with it, on the spot.
139 // That is right for a tab closed for good and wrong for the commonest way a
140 // page goes away, which is a RELOAD: F5, a crash, `dev/serve.mjs` restarting,
141 // or the app's own 426 heal. A daimon that started a dev server and a build
142 // lost both to a keypress, and the listing afterwards was honestly empty --
143 // which is the worst of both, because nothing anywhere said a thing had been
144 // stopped.
145 //
146 // The owner chose this shape on 2026-08-25, over "keep them until stopped"
147 // and over "keep killing them, but say so": HOLD FOR ABOUT THIRTY SECONDS
148 // AND RE-ATTACH. So a relay whose page has gone is PARKED rather than
149 // stopped -- the host stays connected, its runs keep running -- and the next
150 // page from the same tab adopts it.
151 //
152 // THE TAB IS THE KEY, NOT THE ORIGIN. A reload keeps `sender.tab.id`; a
153 // second tab of the same origin does not. Parking by origin alone would
154 // hand a new tab the runs of a tab that had just been closed, which is
155 // somebody else's compartment. Where Chrome names no tab -- which it does
156 // not for a page connection, but the field is not ours to guarantee -- the
157 // relay is stopped as it always was, because a hold that cannot be aimed is
158 // a hold that reaches the wrong page.
159 //
160 // A PAGE THAT SAYS `bye` IS TAKEN AT ITS WORD and stopped on the spot. The
161 // grace is for a page that VANISHED, which cannot be told from a crash; a
162 // goodbye is a page saying it is finished with this host, and a hold that
163 // ignored it would make the wire's own word mean nothing.
164 //
165 // WHAT ARRIVES WHILE THE PAGE IS AWAY IS HELD, NOT DROPPED. A daimon that
166 // re-attaches and silently misses thirty seconds of a build's output is
167 // being lied to, which is worse than a process that was honestly killed. So
168 // every page-bound message is buffered while parked, and the buffer is
169 // BOUNDED -- a `cargo build` outruns any buffer worth keeping in a service
170 // worker. When the bound bites the oldest go, and how many went is said on
171 // re-attach beside the rest. This file already refuses to hide a sequence
172 // gap for the same reason (`checkSeq`): output the reader believes is
173 // complete is the fault, not output that is short.
174 const HOLD_MS = 30000;
175
176 /// The most a parked relay holds for a page that may be coming back, in
177 /// bytes of JSON and in messages. Two bounds because one message can be a
178 /// megabyte and a thousand can be a byte each.
179 const HELD_BYTES = 512 * 1024;
180 const HELD_MSGS = 4000;
181
182 /// How long the hand is given to name what it is about to lose, when the
183 /// grace runs out. One message each way over a pipe, and a listing that does
184 /// not arrive leaves the count unknown rather than the report unwritten.
185 const RITES_MS = 2000;
186
187 /// Relays whose page has gone and whose thirty seconds have not run out, by
188 /// tab id. At most one per tab: a second park for the same tab can only mean
189 /// the first was never adopted, and it is stopped rather than forgotten.
190 const parked = new Map();
191
192 /// What lapsed, by tab id, so the page that comes back LATE is told rather
193 /// than meeting an empty listing. Read once and cleared -- it is news about
194 /// one gap, not a standing condition.
195 const lapses = new Map();
196
197 // -- What an exec may look like --------------------------------------
198 //
199 // The hand enforces the fence; this file cannot and does not pretend to.
200 // But the page composing the request is not trusted either -- the fence
201 // arrived from the page verbatim until 2026-08-02, so a page chose its own
202 // compartment and the compartment was decoration. These are the shapes the
203 // relay can genuinely rule out from where it stands, and they are a SECOND
204 // line: the durable clamp belongs in the hand, which knows what it granted.
205
206 /// The longest caller-chosen run id. It is echoed on every message about the
207 /// run, so an unbounded one is a frame the hand cannot send.
208 const ID_MAX = 128;
209
210 /// The longest wall-clock limit a command may ask for: a day. Beyond that a
211 /// number is not a timeout, it is the absence of one.
212 const TIMEOUT_MAX = 24 * 60 * 60 * 1000;
213
214 /// Environment names that decide what code a program loads before its own
215 /// `main` runs. `LD_PRELOAD` is the whole family's argument: name it, and the
216 /// command that runs is not the command that was asked for. `hand/README.md`
217 /// says the environment is not the model's for exactly this reason.
218 const ENV_FORBIDDEN = /^(LD_[A-Z0-9_]*|DYLD_[A-Z0-9_]*|GCONV_PATH|BASH_ENV|ENV|BASH_FUNC_.*)$/i;
219
220 /// A shape a POSIX environment name can actually have.
221 const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
222
223 /// Roots no fence may name as writable. `/` is the whole machine, and the
224 /// rest are the places from which the whole machine follows. This is a
225 /// deny-list and therefore not the guarantee -- the guarantee is that the
226 /// hand clamps a fence to what it granted -- but a page asking for `/` is
227 /// answered here rather than one layer further in.
228 const ROOT_FORBIDDEN = new Set([
229 '/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib32', '/lib64',
230 '/media', '/mnt', '/opt', '/proc', '/root', '/run', '/sbin', '/srv',
231 '/sys', '/usr', '/var', '/Users', '/Library', '/System', '/Applications',
232 ]);
233
234 // The sentences the daimon reads. English, and phrased so the model can act
235 // rather than retry. They are assembled once, here, so the wording of a
236 // failure is not scattered through the code that detects it.
237
238 const NOT_INSTALLED =
239 `Daimond's machine hand is not installed on this computer, so no command can be run here. `
240 + `Chrome could not find the native messaging host "${HOST_NAME}". `
241 + `To install it: from the Daimond repository, `
242 + `run "cargo build --release --manifest-path hand/Cargo.toml" and then `
243 + `"hand/install/install.sh --workspace <the folder Daimond may work in>" -- naming the folder `
244 + `in that same command is what saves a second pass, because the hand refuses to serve a page `
245 + `until it has been told which folder it may touch, and it never guesses. `
246 + `Then restart the browser, which reads the registration only at startup. `
247 + `"hand/install/install.sh --check" says what is still wrong, one line each. `
248 + `hand/install/README.md is the whole procedure. `
249 + `Until it is installed, Daimond works in the browser and cannot touch this computer.`;
250
251 // The sentence for a disconnect the hand said nothing about.
252 //
253 // Written for a PERSON, because a person is who reads it: the Terminal panel puts
254 // this line on the screen where the terminal would have been. It used to offer two
255 // causes -- a crash, or a message over the browser's 1 MB frame limit -- which
256 // nobody at the keyboard can tell apart, and then instructed a daimon to "tell the
257 // user to check the hand's journal", to a reader who WAS the user.
258 //
259 // One line, and the one thing worth doing. The second clause names what has been
260 // the real cause on this machine every time it has been chased: one hand per page
261 // port, one record, and a second Daimond window takes the record's lock first.
262 const GONE_UNSAID =
263 `Daimond's machine hand stopped without saying why, so nothing can run on this computer until it is back. `
264 + `Reload the page to start it again -- and if it stops a second time, close any other browser window that has Daimond open, `
265 + `because only one of them at a time can hold the hand.`;
266
267 const FORBIDDEN =
268 `Daimond's machine hand is installed but will not talk to this extension. `
269 + `Its host manifest for "${HOST_NAME}" does not list this extension in allowed_origins, `
270 + `which happens when the extension was loaded unpacked without the pinned key, or when the `
271 + `manifest was written for a different build. Re-running hand/install/install.sh repairs it.`;
272
273 const DECLINED_SENTENCE =
274 `Daimond was refused permission to run commands on this computer, so it will not ask again. `
275 + `To change that, reload the page and allow it when the Daimond Hands window asks; `
276 + `until then the work has to happen in the browser or in the workspace files.`;
277
278 const DISMISSED_SENTENCE =
279 `The approval window for running commands on this computer was closed before it was answered, `
280 + `so nothing may be run. The user may not have seen it -- the Daimond Hands icon carries the `
281 + `question until it is answered. Answer it and try again, or do the work another way.`;
282
283 const NOT_OURS =
284 `This page is not one Daimond Hands answers, so it cannot be given the machine hand. `
285 + `The extension replies to Daimond's own origins and to nothing else.`;
286
287 const REVOKED_SENTENCE =
288 `The user withdrew permission to run commands on this computer, so everything running was stopped. `
289 + `Do not start anything else on this machine until they allow it again from the Daimond Hands icon.`;
290
291 // ------------------------------------------------------------------
292 // State
293 // ------------------------------------------------------------------
294
295 /// One relay per connected page. The map exists so a revocation can reach
296 /// every one of them at once: a permission that is withdrawn while a build
297 /// is running has to stop the build, or it was never a permission.
298 const relays = new Set();
299
300 /// The keep-alive timer, shared by every relay, running only while at least
301 /// one command is in flight anywhere.
302 let awake = null;
303
304 /// Asks the user. Wired by background.js, which owns the grant window, the
305 /// nonce table and the toolbar mark -- this file does not open a second one.
306 let askUser = null;
307 let ALLOWED = 'allowed';
308 let DECLINED = 'declined';
309
310 /// Hands this file the broker's own grant machinery.
311 ///
312 /// It is passed in rather than reached for. Both scripts share one worker
313 /// scope, so `ask` would in fact be visible here by accident of load order,
314 /// and a dependency that works by accident is one that breaks silently when
315 /// the order changes.
316 ///
317 /// # Arguments
318 /// * `fns` - `{ ask, ALLOWED, DECLINED }` from background.js.
319 function wire(fns) {
320 askUser = fns.ask;
321 ALLOWED = fns.ALLOWED;
322 DECLINED = fns.DECLINED;
323 }
324
325 // ------------------------------------------------------------------
326 // The grant
327 // ------------------------------------------------------------------
328
329 /// Everything the user has allowed, by origin.
330 ///
331 /// Storage rather than a Chrome permission, because there is no Chrome
332 /// permission for this. `nativeMessaging` is granted at install and cannot
333 /// be asked for a second time, so it is a capability, not a decision. The
334 /// decision is ours to record, per origin, and to be able to withdraw.
335 async function all() {
336 try {
337 const got = await chrome.storage.local.get(GRANT_KEY);
338 const map = got && got[GRANT_KEY];
339 return (map && typeof map === 'object') ? map : {};
340 } catch (e) {
341 return {};
342 }
343 }
344
345 /// Has the user allowed commands on this machine, to this origin?
346 ///
347 /// # Arguments
348 /// * `origin` - The origin asking, e.g. `https://daimond.oxedyne.com`. With
349 /// none, the question is whether ANY origin holds the grant, which is what
350 /// the popup and the tests ask.
351 async function granted(origin) {
352 const map = await all();
353 if (!origin) return Object.keys(map).some((o) => map[o] && map[o].at);
354 return !!(map[origin] && map[origin].at);
355 }
356
357 /// What the popup lists, one line per origin that holds the grant.
358 async function patterns() {
359 const map = await all();
360 return Object.keys(map)
361 .filter((o) => map[o] && map[o].at)
362 .map((o) => PATTERN + PATTERN_SEP + o);
363 }
364
365 /// The origin a popup pattern is about, or '' when it is not one of ours.
366 ///
367 /// # Arguments
368 /// * `pat` - What the Revoke button sent back.
369 function originOfPattern(pat) {
370 if (typeof pat !== 'string') return '';
371 if (pat === PATTERN) return '';
372 if (pat.indexOf(PATTERN + PATTERN_SEP) !== 0) return '';
373 return pat.slice(PATTERN.length + PATTERN_SEP.length);
374 }
375
376 /// Whether a pattern belongs to this grant at all, so the broker can route
377 /// a Revoke to us rather than to Chrome's permission system.
378 function ours(pat) {
379 return pat === PATTERN || !!originOfPattern(pat);
380 }
381
382 /// Puts the question to the user, in the extension's own window.
383 ///
384 /// The same window, the same nonce, the same three answers as a site
385 /// approval: allowed, declined, or a window that went away unseen. The last
386 /// is not a refusal, and the daimon is told which it was, because one means
387 /// stop asking and the other means ask again.
388 ///
389 /// `caps` is what the hand said it can enforce on THIS machine, and the
390 /// window's wording is chosen from it. A machine with no fence must not be
391 /// described in the sentence written for one that has it: the promise the
392 /// window makes is the only thing the user has to go on.
393 ///
394 /// # Arguments
395 /// * `origin` - Who is asking.
396 /// * `caps` - The `caps` list from the hand's `hello`, or null if it never
397 /// said.
398 ///
399 /// # Returns
400 /// True if commands may now be run for that origin.
401 async function askFor(origin, caps) {
402 if (!origin) return DECLINED;
403 if (await granted(origin)) return true;
404 if (!askUser) return false;
405 const answer = await askUser({
406 kind: 'hand',
407 origin: origin,
408 // A space-separated list, because this crosses a URL into the grant
409 // window. Absent means the hand never said, which is a third case
410 // and is worded as one.
411 caps: Array.isArray(caps) ? caps.join(' ') : '',
412 });
413 if (answer !== ALLOWED) return answer;
414 const map = await all();
415 map[origin] = { at: Date.now(), caps: Array.isArray(caps) ? caps : [] };
416 await chrome.storage.local.set({ [GRANT_KEY]: map });
417 return true;
418 }
419
420 /// Withdraws it, and stops everything it allowed.
421 ///
422 /// Revocation that let the current build finish would be a promise with an
423 /// asterisk on it. Every host of that origin is disconnected, which is what
424 /// kills the processes: the hand exits when its port closes.
425 ///
426 /// # Arguments
427 /// * `origin` - The one to withdraw. With none, all of them.
428 async function revoke(origin) {
429 if (origin) {
430 const map = await all();
431 delete map[origin];
432 await chrome.storage.local.set({ [GRANT_KEY]: map });
433 } else {
434 await chrome.storage.local.remove(GRANT_KEY);
435 }
436 for (const r of [...relays]) {
437 if (!origin || r.origin === origin) r.stop(REVOKED_SENTENCE);
438 }
439 return true;
440 }
441
442 // ------------------------------------------------------------------
443 // The boundary
444 // ------------------------------------------------------------------
445
446 /// Every origin pattern the manifest lets speak to us, parsed.
447 ///
448 /// Read from the manifest rather than from a second list that could drift,
449 /// because the boundary is the product.
450 function ourPatterns() {
451 const m = chrome.runtime.getManifest();
452 const pats = (m.externally_connectable && m.externally_connectable.matches) || [];
453 const out = [];
454 for (const p of pats) {
455 const hit = /^(\*|https?):\/\/(\*\.)?([^/*]+)\//.exec(p);
456 if (!hit) continue;
457 out.push({ scheme: hit[1], sub: !!hit[2], host: hit[3].toLowerCase() });
458 }
459 return out;
460 }
461
462 /// The origin this sender is, if the manifest allows it, and '' otherwise.
463 ///
464 /// The PORT IS PART OF IT. The previous version of this function also added
465 /// each pattern's host with the port stripped off, so it would have accepted
466 /// `127.0.0.1:8778` on the strength of a pattern naming `127.0.0.1:8777` --
467 /// a different origin, a different program, a different person. Chrome
468 /// honours the port in `externally_connectable` and so nothing was
469 /// exploitable through it, which is exactly the trouble with a second check
470 /// that is laxer than the first: it is load-bearing only on the day the
471 /// first one changes, and on that day it fails open.
472 ///
473 /// `sender.origin` is Chrome's own answer and is preferred; `sender.url` is
474 /// the fallback for a Chrome that did not send one.
475 ///
476 /// # Arguments
477 /// * `sender` - The `MessageSender` Chrome handed us.
478 function allowedOrigin(sender) {
479 let u;
480 try {
481 u = new URL((sender && (sender.origin || sender.url)) || '');
482 } catch (e) {
483 return '';
484 }
485 const scheme = u.protocol.replace(/:$/, '').toLowerCase();
486 const host = u.host.toLowerCase(); // with the port, where there is one
487 const name = u.hostname.toLowerCase(); // without it
488 for (const p of ourPatterns()) {
489 if (p.scheme !== '*' && p.scheme !== scheme) continue;
490 // A pattern that names a port must match it exactly; one that does
491 // not is about the default port and is compared without one.
492 const want = p.host;
493 if (/:\d+$/.test(want) ? want === host : (want === name && want === host)) {
494 return u.origin;
495 }
496 // `*.example.com` covers subdomains, and only downward.
497 if (p.sub && !/:\d+$/.test(want) && name.endsWith('.' + want) && name === host) {
498 return u.origin;
499 }
500 }
501 return '';
502 }
503
504 /// Is this connection from a page we answer?
505 function mayConnect(sender) {
506 return !!allowedOrigin(sender);
507 }
508
509 // ------------------------------------------------------------------
510 // Paths
511 //
512 // Enough of one to compare two the way the hand does, and no more. The
513 // authority on what a path means is the machine it is on; these three
514 // answer the questions that can be settled without asking it.
515 // ------------------------------------------------------------------
516
517 /// Is this path absolute, in either of the two spellings the hand runs on?
518 ///
519 /// # Arguments
520 /// * `p` - The path as it was written.
521 function absolute(p) {
522 return /^\//.test(p) || /^[A-Za-z]:[\\/]/.test(p);
523 }
524
525 /// The named components of a path, with the empties dropped.
526 function segments(p) {
527 return String(p).split(/[\\/]+/).filter(Boolean);
528 }
529
530 /// Is `p` the same folder as `root`, or one beneath it?
531 ///
532 /// Compared component by component, so `/workshop` is not inside `/work` --
533 /// the same rule `exec.rs` applies with `Path::starts_with`, and the reason a
534 /// string prefix will not do. Neither side is resolved: a symbolic link is
535 /// the machine's business and this end cannot see one.
536 ///
537 /// # Arguments
538 /// * `p` - The candidate path.
539 /// * `root` - The root it might sit under.
540 function under(p, root) {
541 const a = segments(p);
542 const b = segments(root);
543 if (b.length > a.length) return false;
544 for (let i = 0; i < b.length; i++) if (a[i] !== b[i]) return false;
545 return true;
546 }
547
548 // ------------------------------------------------------------------
549 // The relay
550 // ------------------------------------------------------------------
551
552 /// One page port, one host port, and the bookkeeping that lets a failure be
553 /// described rather than merely noticed.
554 ///
555 /// # Arguments
556 /// * `page` - The port the Daimond page opened.
557 /// * `origin` - Which Daimond origin it is, already checked.
558 /// * `tabId` - The tab it came from, which a reload keeps and a new tab does
559 /// not, or 0 where Chrome named none.
560 function relay(page, origin, tabId) {
561 /// The port to the page, swapped for a new one when a reloaded page
562 /// adopts this relay, and null while nothing is attached.
563 let wire = page;
564 /// The native port, or null once it has gone.
565 let host = null;
566 /// True once we have deliberately closed the pair, so the disconnect
567 /// that follows is not reported as a surprise.
568 let closing = false;
569 /// Runs believed to be in flight, by id. The value carries the last seq
570 /// seen on each stream, so a gap is a comparison rather than a guess.
571 const runs = new Map();
572 /// The largest message the host has sent. Only used to make the report
573 /// after a silent disconnect more useful than "it went away".
574 let biggest = 0;
575 /// The sentence the hand sent on its way out, where it sent one.
576 ///
577 /// A hand that will not start -- no granted root, a record it cannot open,
578 /// a second hand already holding one -- knows exactly why, and used to
579 /// write it to a standard error the browser discards. It now sends it as a
580 /// `fault` frame before it exits, so `hostGone` has the hand's own answer
581 /// instead of a guess between two causes it cannot tell apart.
582 let lastFault = '';
583 /// What the page said before the host was up.
584 ///
585 /// A page connects and greets in the same breath, and between those two
586 /// moments sits the grant window, which a person may take a minute over.
587 /// Refusing the greeting because we were still asking would make the
588 /// first connection of a fresh install fail for a reason that is not a
589 /// failure. So it waits here, in order, and goes out in order.
590 let outbox = [];
591 /// The folder the hand says its grant covers, from its `hello`, or ''
592 /// while it has not said. When it has, no fence may name a root outside
593 /// it; when it has not, the relay can only refuse the roots that are
594 /// wrong on any machine.
595 let hostRoot = '';
596 /// The home directory the hand reports, from its `hello`, or '' while it
597 /// has not said.
598 ///
599 /// A toolchain does not live in the workspace: `cargo` is under
600 /// `~/.cargo`, `node` under `~/.nvm`, and a fence that must reach one of
601 /// them names a folder outside the grant by construction. Refusing every
602 /// such root -- which this relay did -- refuses every build the toolkit
603 /// feature exists to enable, and the page was left holding a refusal
604 /// about a folder the model can do nothing about while the daimon had
605 /// been told `cargo` was on its PATH.
606 ///
607 /// So a root outside the grant is allowed through HERE when the same
608 /// request says a toolkit was granted and the root is inside this home
609 /// directory. That is deliberately the loose half of the answer: WHICH
610 /// folders each toolkit reaches, and at which level, is a table the hand
611 /// holds and checks exactly (`vet_roots` in `hand/src/exec.rs`). A second
612 /// copy of that table here would be a second answer to the same question,
613 /// free to drift from the one that is enforced.
614 let hostHome = '';
615 /// The folders this machine will let a TERMINAL be fenced to, from `terminal-ceiling:`
616 /// in the hand's own `hello`.
617 ///
618 /// A terminal is the user at a keyboard and a command is a daimon, so the two are
619 /// allowed different sizes -- and the list comes from the MACHINE, never from the page,
620 /// which is what keeps this a clamp rather than a formality. Empty on an older hand,
621 /// and then a terminal is held to the granted root exactly as it always was.
622 let hostCeilings = [];
623 /// Waiting for the hand to say what it can enforce, before the user is
624 /// asked. Null once that is settled, one way or another.
625 let capsWait = null;
626 /// Waiting for the hand to name what the grace is about to take with it.
627 /// Null except during those two seconds.
628 let ritesWait = null;
629 /// The timer counting out the grace, or null while a page is attached.
630 let holding = null;
631 /// The grace has run out and the last rites are being read. The relay is
632 /// still in `parked` through those two seconds, so a page arriving in
633 /// them adopts it and the lapse is abandoned rather than stopping a hand
634 /// the page has just taken back.
635 let dying = false;
636 /// When the page went, so a re-attach can say how long it was away.
637 let wentAt = 0;
638 /// What arrived while nothing was attached, oldest first, and what had
639 /// to be let go to keep it bounded.
640 let held = [];
641 let heldBytes = 0;
642 let dropped = 0;
643 let droppedBytes = 0;
644
645 const self = {
646 stop,
647 adopt,
648 lapsed,
649 origin,
650 tabId,
651 // A parked relay is BUSY. Not because anything is necessarily
652 // running -- it may be holding nothing but a buffer -- but because
653 // an MV3 worker evicted mid-grace takes the native port with it, and
654 // the grace would then be thirty seconds that sometimes happen.
655 busy: () => runs.size > 0 || holding !== null || dying,
656 };
657
658 /// Says something to the page, if it is still there.
659 ///
660 /// One postMessage per message, always. Coalescing two chunks would
661 /// destroy the attribution the seq exists to provide, and buffering to
662 /// "smooth" the stream would turn live output into a report.
663 function say(m) {
664 if (!wire) { hold(m); return; }
665 try {
666 wire.postMessage(m);
667 } catch (e) {
668 // The page has gone. Its own disconnect handler is about to run
669 // and will park or stop this relay.
670 }
671 }
672
673 /// Keeps one page-bound message for a page that may be coming back.
674 ///
675 /// The bound is on the BUFFER and the loss is COUNTED, so a re-attach
676 /// that is short of output says how short. Dropping the oldest rather
677 /// than refusing the newest is deliberate: the end of a build is what a
678 /// reader wants, and the beginning is what they already saw.
679 function hold(m) {
680 let size = 0;
681 try { size = JSON.stringify(m).length; } catch (e) { size = 0; }
682 held.push({ m, size });
683 heldBytes += size;
684 while (held.length > HELD_MSGS || heldBytes > HELD_BYTES) {
685 const gone = held.shift();
686 if (!gone) break;
687 heldBytes -= gone.size;
688 dropped++;
689 droppedBytes += gone.size;
690 }
691 }
692
693 /// The plain-English shape of an error to the daimon.
694 function fail(id, message) {
695 say({ t: 'error', id: id || null, message });
696 }
697
698 /// Closes out a run the page will otherwise wait for ever on.
699 ///
700 /// An `ended` is owed for every `started`. When the host dies there is
701 /// no exit status to report, so it is reported as the absence of one:
702 /// exit -1, killed, and the error above it says why.
703 function abandon(id) {
704 say({ t: 'ended', id, exit: -1, timed_out: false, killed: true, out_bytes: 0, err_bytes: 0 });
705 }
706
707 /// Tears the pair down and tells the page why.
708 ///
709 /// # Arguments
710 /// * `why` - The sentence the daimon reads, or empty for an orderly close.
711 function stop(why) {
712 closing = true;
713 relays.delete(self);
714 // Stopped while parked -- revoked, or the hand died in the grace --
715 // so the page that comes back must be told rather than meeting an
716 // empty listing with nothing to explain it. `lapse` has already
717 // written its own record; this covers every other way out.
718 if (holding !== null || dying) {
719 clearTimeout(holding);
720 holding = null;
721 dying = false;
722 parked.delete(tabId);
723 if (!lapses.has(tabId)) {
724 lapses.set(tabId, {
725 at: Date.now(),
726 away: Math.max(0, Date.now() - wentAt),
727 ids: [...runs.keys()],
728 unknown: true,
729 why: why || '',
730 dropped,
731 droppedBytes,
732 });
733 }
734 }
735 if (ritesWait) { const settle = ritesWait; ritesWait = null; settle({ ids: [], unknown: true }); }
736 // Whoever is waiting on the hand's capabilities is waiting on a hand
737 // that has gone. Let them get on with it rather than sit out the
738 // timeout.
739 if (capsWait) { const settle = capsWait; capsWait = null; settle({ gone: true }); }
740 if (why) {
741 fail(null, why);
742 for (const id of runs.keys()) abandon(id);
743 }
744 runs.clear();
745 breathe();
746 if (host) {
747 // `bye` first, so a hand that is between commands exits of its
748 // own accord and does not have to be reaped. The disconnect is
749 // what actually guarantees it.
750 try { host.postMessage({ t: 'bye' }); } catch (e) { /* already gone */ }
751 try { host.disconnect(); } catch (e) { /* already gone */ }
752 host = null;
753 }
754 if (wire) { try { wire.disconnect(); } catch (e) { /* already gone */ } }
755 wire = null;
756 held = [];
757 heldBytes = 0;
758 }
759
760 // -- The grace ------------------------------------------------------
761
762 /// The page went. Hold what it left running, for the length of the grace.
763 ///
764 /// Stopping outright is kept for the two cases a hold cannot serve: no
765 /// host, so there is nothing to hold; and no tab id, so a hold could not
766 /// be aimed at the page that comes back and would be offered to whichever
767 /// page connected next.
768 function park() {
769 // A PAGE THAT SAID GOODBYE IS NOT A PAGE THAT VANISHED. The grace is
770 // for the second, which cannot be told from a crash or a tab closing
771 // for good; `bye` is the wire's word for "I am finished with this
772 // host", and honouring it at once is what makes the two different
773 // things. `fromPage` sets `closing` on one and nothing else does.
774 //
775 // It is also a LEAK if it is not honoured: a relay whose page said
776 // bye and then disconnected would sit in `relays` with a live host
777 // on the end of it and no timer to end it.
778 if (closing) { stop(''); return; }
779 if (!host || !tabId) { stop(''); return; }
780 const was = parked.get(tabId);
781 if (was && was !== self) was.stop('');
782 wire = null;
783 wentAt = Date.now();
784 parked.set(tabId, self);
785 holding = setTimeout(() => { lapse(); }, HOLD_MS);
786 breathe();
787 }
788
789 /// The grace ran out and nothing came back.
790 async function lapse() {
791 holding = null;
792 dying = true;
793 const what = await lastRites();
794 // Adopted while the hand was being asked. The page has it back, so
795 // there is nothing to report and nothing to stop.
796 if (!dying) return;
797 dying = false;
798 parked.delete(tabId);
799 lapses.set(tabId, {
800 at: Date.now(),
801 away: HOLD_MS,
802 ids: what.ids,
803 unknown: what.unknown,
804 why: '',
805 dropped,
806 droppedBytes,
807 });
808 stop('');
809 }
810
811 /// Asks the hand what the stop is about to take with it.
812 ///
813 /// The relay's own `runs` map holds what is IN FLIGHT and not what is
814 /// STANDING -- a `sleep 300 &` left by a command that already ended has
815 /// no entry here and is the commonest thing a reload loses. So the hand
816 /// is asked, because the hand is the one that knows.
817 function lastRites() {
818 return new Promise((resolve) => {
819 if (!host) { resolve({ ids: [...runs.keys()], unknown: true }); return; }
820 let settled = false;
821 const once = (v) => {
822 if (settled) return;
823 settled = true;
824 clearTimeout(timer);
825 ritesWait = null;
826 resolve(v);
827 };
828 const timer = setTimeout(() => once({ ids: [...runs.keys()], unknown: true }), RITES_MS);
829 ritesWait = once;
830 try { host.postMessage({ t: 'runs' }); }
831 catch (e) { once({ ids: [...runs.keys()], unknown: true }); }
832 });
833 }
834
835 /// A reloaded page takes this relay, and everything held for it, over.
836 ///
837 /// # Arguments
838 /// * `port` - The port the returning page opened.
839 function adopt(port) {
840 if (holding !== null) { clearTimeout(holding); holding = null; }
841 dying = false;
842 parked.delete(tabId);
843 wire = port;
844 port.onMessage.addListener(fromPage);
845 port.onDisconnect.addListener(pageGone);
846 const away = Math.max(0, Date.now() - wentAt);
847 const batch = held;
848 const lost = dropped;
849 const bytes = droppedBytes;
850 wentAt = 0;
851 held = [];
852 heldBytes = 0;
853 dropped = 0;
854 droppedBytes = 0;
855 // Said BEFORE the replay, so a reader meets the warning about a hole
856 // ahead of the output that has one -- the same order `checkSeq` puts
857 // a gap in.
858 say({
859 t: 'resumed',
860 away_ms: away,
861 held: batch.length,
862 dropped: lost,
863 dropped_bytes: bytes,
864 ids: [...runs.keys()],
865 });
866 for (const h of batch) say(h.m);
867 breathe();
868 }
869
870 /// Tells a fresh page what the grace took, when it came back too late.
871 ///
872 /// # Arguments
873 /// * `gap` - The record `lapse` or `stop` left behind for this tab.
874 function lapsed(gap) {
875 say({
876 t: 'lapsed',
877 away_ms: gap.away,
878 hold_ms: HOLD_MS,
879 ids: Array.isArray(gap.ids) ? gap.ids : [],
880 unknown: !!gap.unknown,
881 why: gap.why || '',
882 dropped: gap.dropped || 0,
883 });
884 }
885
886 /// The host sent something. Forward it, in order, having first checked
887 /// the one property the page cannot check for itself.
888 function fromHost(m) {
889 if (!m || typeof m !== 'object' || typeof m.t !== 'string') {
890 fail(null, 'The machine hand sent something that is not a wire message, so it cannot be used. Reinstall it with hand/install/install.sh and reload the page.');
891 return;
892 }
893
894 try {
895 const size = JSON.stringify(m).length;
896 if (size > biggest) biggest = size;
897 } catch (e) { /* unmeasurable; the forward still happens */ }
898
899 if (m.t === 'fault') {
900 // The hand's own last word, and the only message that arrives before
901 // the greeting. It is a whole sentence written for a PERSON, so it is
902 // passed on unchanged rather than wrapped in this file's vocabulary.
903 lastFault = typeof m.reason === 'string' ? m.reason : '';
904 stop(lastFault || GONE_UNSAID);
905 return;
906 }
907
908 if (m.t === 'hello') {
909 // What the hand can enforce, and the folder it says the grant
910 // covers. Both are read on every hello, not only the first, so a
911 // hand that reconnects to a different folder is believed about
912 // the folder it is in now. `wire.rs` has no field for the
913 // folder, so it arrives as a `root:<path>` capability; a later
914 // wire that grows a field of its own is read too, and wins.
915 for (const c of (Array.isArray(m.caps) ? m.caps : [])) {
916 if (typeof c === 'string' && c.indexOf('root:') === 0) hostRoot = c.slice(5);
917 if (typeof c === 'string' && c.indexOf('home:') === 0) hostHome = c.slice(5);
918 if (typeof c === 'string' && c.indexOf('terminal-ceiling:') === 0) {
919 const cp = c.slice('terminal-ceiling:'.length);
920 if (cp && hostCeilings.indexOf(cp) < 0) hostCeilings.push(cp);
921 }
922 }
923 if (typeof m.root === 'string' && m.root) hostRoot = m.root;
924 if (capsWait) {
925 const settle = capsWait;
926 capsWait = null;
927 settle({ caps: Array.isArray(m.caps) ? m.caps : [] });
928 // The hello that answered OUR question is ours. Forwarding it
929 // would hand the page an answer to a greeting it never sent.
930 return;
931 }
932 }
933
934 if (m.t === 'runs' && ritesWait) {
935 // Ours, not the page's: nobody out there asked for it, and a page
936 // that met it would settle a waiter it never armed.
937 const settle = ritesWait;
938 ritesWait = null;
939 const rows = Array.isArray(m.runs) ? m.runs : [];
940 settle({
941 ids: rows.map((r) => (r && r.id)).filter((x) => typeof x === 'string' && x),
942 unknown: false,
943 });
944 return;
945 }
946
947 // A HANDSHAKE REFUSAL IS THE HAND'S LAST WORD, and it is followed by an
948 // exit. Held here so `hostGone` says what the hand said rather than
949 // composing a guess over the top of it: on 2026-08-26 the one sentence
950 // that ended the hunt was produced, delivered, and then overwritten by
951 // `GONE_UNSAID` before anyone could read it.
952 if (m.t === 'refused' && m.id === 'hello' && typeof m.reason === 'string') {
953 lastFault = m.reason;
954 }
955
956 // A GRANT CHANGES THE FOLDER THE NEXT HAND WILL WORK IN, and a hand reads its
957 // root once at startup -- so the change is invisible until one actually starts.
958 // A page reload does not do it: the relay parks its host for the grace and the
959 // returning page adopts the SAME process, so the folder went on being the old one
960 // and the settings row went on showing it. That read exactly like a grant that had
961 // not worked, which is what the owner reported on 2026-08-27 with `root.txt`
962 // already correct on disk.
963 //
964 // So the host is let go here, after the answer has been forwarded. The next thing
965 // the page asks for launches a hand that reads the file.
966 if (m.t === 'granted') {
967 say(m);
968 // `hostGone` fires on the disconnect below, and without a sentence of its own it
969 // would tell the page the hand stopped without saying why -- which is the one
970 // thing that did not happen. `lastFault` is the seam that already exists for
971 // "the hand's own last word", and this is one.
972 lastFault = 'The folder was changed, so the machine hand was let go. It starts '
973 + 'again with the new folder the next time anything needs it.';
974 try { host.disconnect(); } catch (e) { /* already gone */ }
975 hostGone();
976 return;
977 }
978
979 if (m.t === 'started' || m.t === 'opened') {
980 runs.set(m.id, { out: null, err: null });
981 breathe();
982 } else if (m.t === 'chunk' || m.t === 'output') {
983 checkSeq(m);
984 } else if (m.t === 'ended' || m.t === 'refused' || m.t === 'closed' || m.t === 'filed') {
985 // A `filed` is the whole of a file operation's answer: there is no `started`
986 // before it and no `ended` after it, so it is what closes the registry entry
987 // `fromPage` opened. Left out, the entry would be permanent -- and the
988 // abandonment path below would owe the page an `ended` for something that
989 // never started.
990 runs.delete(m.id);
991 breathe();
992 }
993
994 say(m);
995 }
996
997 /// Watches the per-stream sequence, and says so when it jumps.
998 ///
999 /// The first chunk of a stream sets the baseline -- the hand's own
1000 /// numbering is its business -- and every one after it must be exactly
1001 /// one more. A gap is announced BEFORE the chunk that revealed it, so a
1002 /// reader assembling the output meets the warning at the point the
1003 /// output is wrong, not after the whole run.
1004 function checkSeq(m) {
1005 const r = runs.get(m.id);
1006 if (!r) return; // Output for a run we never saw start; the host owns that story.
1007 // A terminal has one stream by construction and sends no `stream` field, so
1008 // it is watched on the `out` line. Reading `m.stream` as absent-means-out
1009 // happens to be right for both, but it is written down because it is a
1010 // coincidence rather than a shared meaning.
1011 const s = m.stream === 'err' ? 'err' : 'out';
1012 if (typeof m.seq !== 'number') return;
1013 const last = r[s];
1014 if (last !== null && m.seq !== last + 1) {
1015 fail(m.id, m.seq < last
1016 ? `Output from ${m.id} on ${s} went backwards, from sequence ${last} to ${m.seq}. The stream is not in order and what follows cannot be trusted as a transcript.`
1017 : `Output from ${m.id} on ${s} jumped from sequence ${last} to ${m.seq}, so ${m.seq - last - 1} chunk(s) are missing. What follows has a hole in it.`);
1018 }
1019 r[s] = m.seq;
1020 }
1021
1022 /// The host port went away. This is the ambiguous event, and the whole
1023 /// job here is to make it less ambiguous than Chrome left it.
1024 function hostGone() {
1025 const why = (chrome.runtime.lastError && chrome.runtime.lastError.message) || '';
1026 host = null;
1027 if (closing) return;
1028
1029 // Not installed is the first-run failure, and it is worth its own
1030 // sentence: Chrome's own wording names a string the user has never
1031 // heard of and no action at all.
1032 if (/not found|no such native|Specified native messaging host/i.test(why)) {
1033 stop(NOT_INSTALLED);
1034 return;
1035 }
1036 if (/forbidden|not allowed/i.test(why)) {
1037 stop(FORBIDDEN);
1038 return;
1039 }
1040
1041 // The hand's own last word, where it managed to send one. Always better
1042 // than anything composable here: it knows which reason it was, and this
1043 // end can only guess between reasons Chrome reports identically.
1044 if (lastFault) {
1045 stop(lastFault);
1046 return;
1047 }
1048
1049 // Nothing said. A crash and a message over the browser's frame limit
1050 // arrive as the same disconnect, so the two are not handed over as a
1051 // choice the reader cannot make: what is said is the thing to do, and the
1052 // measurement only where it actually points at the limit.
1053 const n = runs.size;
1054 const big = biggest > FROM_HOST_MAX / 2;
1055 stop(GONE_UNSAID
1056 + (big ? ` The largest message it sent was ${biggest} bytes, near the ${FROM_HOST_MAX} byte limit a browser drops a connection over, so a narrower command may get through.` : '')
1057 + (n ? ` ${n} command(s) were running; their results are lost.` : ''));
1058 }
1059
1060 /// The page sent something. Check what a malformed frame would cost,
1061 /// then forward it unchanged.
1062 function fromPage(m) {
1063 if (closing) {
1064 fail(m && m.id, 'This connection to the machine hand is closing, so nothing more can be sent on it. Open a new one.');
1065 return;
1066 }
1067 if (!m || typeof m !== 'object' || typeof m.t !== 'string') {
1068 fail(null, 'Every message to the machine hand needs a "t" saying which it is: hello, exec, open, file, verify, input, resize, signal, runs or bye.');
1069 return;
1070 }
1071
1072 switch (m.t) {
1073 case 'hello':
1074 break;
1075 case 'exec': {
1076 const bad = wrongExec(m);
1077 if (bad) {
1078 say({ t: 'refused', id: String(m.id || ''), reason: bad });
1079 return;
1080 }
1081 let size = 0;
1082 try { size = JSON.stringify(m).length; } catch (e) { size = 0; }
1083 if (size > TO_HOST_MAX) {
1084 say({
1085 t: 'refused',
1086 id: String(m.id),
1087 reason: `That command is ${size} bytes to send, over the ${TO_HOST_MAX} byte limit for a message to the machine hand. `
1088 + `Chrome would drop the connection rather than deliver it, killing everything else running. `
1089 + `Write the input to a file and have the command read the file instead.`,
1090 });
1091 return;
1092 }
1093 // Registered on the way OUT, not on `started`, so a command the
1094 // host dies before acknowledging is still one the page is owed
1095 // an answer about.
1096 if (!runs.has(m.id)) runs.set(m.id, { out: null, err: null });
1097 breathe();
1098 break;
1099 }
1100 case 'open': {
1101 const bad = wrongOpen(m);
1102 if (bad) {
1103 say({ t: 'refused', id: String(m.id || ''), reason: bad });
1104 return;
1105 }
1106 // Registered like an exec, and for the same reason: a session the
1107 // host dies before acknowledging is still one the page is owed an
1108 // answer about.
1109 if (!runs.has(m.id)) runs.set(m.id, { out: null, err: null });
1110 breathe();
1111 break;
1112 }
1113 case 'input':
1114 if (!m.id || typeof m.id !== 'string') {
1115 fail(null, 'Input needs the id of the terminal it is for.');
1116 return;
1117 }
1118 if (typeof m.data !== 'string' || !BASE64.test(m.data)) {
1119 fail(m.id, 'Terminal input must be base64 of the bytes typed. A terminal carries bytes, not text: '
1120 + 'an arrow key and a Ctrl-C are not characters.');
1121 return;
1122 }
1123 // Deliberately NOT logged, counted or held anywhere on the way past.
1124 // This is the message a password is typed into.
1125 break;
1126 case 'resize':
1127 if (!m.id || typeof m.id !== 'string') {
1128 fail(null, 'A resize needs the id of the terminal it is for.');
1129 return;
1130 }
1131 if (!m.size || !Number.isInteger(m.size.cols) || !Number.isInteger(m.size.rows)
1132 || m.size.cols < 1 || m.size.rows < 1 || m.size.cols > 2000 || m.size.rows > 2000) {
1133 fail(m.id, 'A resize needs size.cols and size.rows as whole numbers of cells, each between 1 and 2000.');
1134 return;
1135 }
1136 break;
1137 case 'file': {
1138 const bad = wrongFile(m);
1139 if (bad) {
1140 say({ t: 'refused', id: String(m.id || ''), reason: bad });
1141 return;
1142 }
1143 let size = 0;
1144 try { size = JSON.stringify(m).length; } catch (e) { size = 0; }
1145 if (size > TO_HOST_MAX) {
1146 say({
1147 t: 'refused',
1148 id: String(m.id),
1149 reason: `That file request is ${size} bytes to send, over the ${TO_HOST_MAX} byte limit for a message to the machine hand. `
1150 + `Chrome would drop the connection rather than deliver it, killing everything else running. `
1151 + `Change the file in smaller pieces: file_edit sends only the two strings, not the whole file.`,
1152 });
1153 return;
1154 }
1155 // Registered like an exec, and for the same reason: an operation the host dies
1156 // before acknowledging is still one the page is owed an answer about -- and it
1157 // is the one whose answer matters most, because "did the write land" cannot be
1158 // inferred from silence.
1159 if (!runs.has(m.id)) runs.set(m.id, { out: null, err: null });
1160 breathe();
1161 break;
1162 }
1163 case 'verify': {
1164 const bad = wrongVerify(m);
1165 if (bad) {
1166 say({ t: 'refused', id: String(m.id || ''), reason: bad });
1167 return;
1168 }
1169 // Registered like an exec, and for the same reason: a sequence the host dies
1170 // before acknowledging is still one the page is owed an answer about.
1171 if (!runs.has(m.id)) runs.set(m.id, { out: null, err: null });
1172 breathe();
1173 break;
1174 }
1175 case 'signal':
1176 if (!m.id || typeof m.id !== 'string') {
1177 fail(null, 'A signal needs the id of the run it is for.');
1178 return;
1179 }
1180 // The wire takes three and no more, and a word outside them is
1181 // refused HERE rather than at the host: the host answers an
1182 // unreadable frame with a decode error naming the field, which
1183 // reads as the hand being broken rather than as the word being
1184 // wrong. There is deliberately no default: a signal nobody named
1185 // is not a `term` somebody would have chosen.
1186 if (m.sig !== 'term' && m.sig !== 'kill' && m.sig !== 'int') {
1187 fail(m.id, `A signal must name "term" (ask it to stop), "kill" (insist) or "int" (interrupt, as Ctrl-C would); this one named ${JSON.stringify(m.sig)}.`);
1188 return;
1189 }
1190 break;
1191 // Takes nothing, so there is nothing to check. It was reaching the
1192 // default below -- which refuses -- so a page could be TOLD by the
1193 // hand that a command had left a server standing and had no way to
1194 // ask what was standing or to stop it. The hand grew `runs` on
1195 // 2026-08-23 and this end went on denying it.
1196 case 'runs':
1197 break;
1198 // A folder browser: directory NAMES, so a person can choose a folder and get its
1199 // real path. Nothing is run and nothing is read, and the hand bounds it to what it
1200 // would fence a terminal to -- so this end checks the shape and forwards.
1201 // Recording the folder the user chose after walking the machine's own. The page
1202 // proposes; the HAND refuses `/`, a non-directory, and any folder containing its
1203 // own record -- a fenced command able to reach the record could rewrite the record
1204 // of what it did.
1205 case 'grant':
1206 if (typeof m.path !== 'string' || !m.path) {
1207 fail(m.id, 'A grant needs the folder as an absolute path.');
1208 return;
1209 }
1210 break;
1211 case 'dirs':
1212 if (m.path !== undefined && typeof m.path !== 'string') {
1213 fail(m.id, 'A folder listing takes a path as a string, or nothing at all to ask where to start.');
1214 return;
1215 }
1216 break;
1217 case 'bye':
1218 closing = true;
1219 break;
1220 default:
1221 fail(m.id, `The machine hand does not know the message "${m.t}". It understands hello, exec, open, file, verify, input, resize, signal, runs, dirs, grant and bye.`);
1222 return;
1223 }
1224
1225 if (!host) {
1226 // Still asking the user, or still starting. Hold it in order.
1227 if (outbox.length >= 64) {
1228 fail(m.id, 'Too much was sent to the machine hand before it was ready. Wait for the "hello" it answers with before sending commands.');
1229 return;
1230 }
1231 outbox.push(m);
1232 return;
1233 }
1234
1235 try {
1236 host.postMessage(m);
1237 } catch (e) {
1238 stop(`The machine hand could not be reached: ${(e && e.message) || e}. It has probably exited.`);
1239 }
1240 }
1241
1242 /// What is wrong with this exec, in the sentence the model reads, or
1243 /// null when there is nothing wrong with it.
1244 ///
1245 /// This is not the fence and it does not pretend to be: the hand enforces
1246 /// what a command may touch, and only the hand knows what it granted. But
1247 /// until 2026-08-02 this function checked `id`, `argv` and `cwd` and
1248 /// forwarded `env`, `fence`, `timeout_ms` and `capture` verbatim -- and
1249 /// forwarded an exec with no `fence` key at all. A page therefore chose
1250 /// its own compartment, which makes the compartment decoration. A
1251 /// reviewer sent `fence:{rw:["/"],net:true}` with its own `LD_PRELOAD`
1252 /// and the hand received it byte for byte.
1253 ///
1254 /// So this is the SECOND line, and it refuses the four things the relay
1255 /// can be sure about from where it stands: a missing fence, a fence
1256 /// naming the machine rather than a folder, an environment that decides
1257 /// what code a program loads, and an id or a timeout with no bound on it.
1258 /// Everything else is the hand's to clamp.
1259 function wrongExec(m) {
1260 if (!m.id || typeof m.id !== 'string') {
1261 return 'Every exec needs an id, which every answer about it is tagged with.';
1262 }
1263 if (m.id.length > ID_MAX) {
1264 return `That id is ${m.id.length} characters, over the ${ID_MAX} the hand carries. It is echoed on every `
1265 + `message about the run, so a long one makes answers the hand cannot send. Use a short handle.`;
1266 }
1267 // eslint-disable-next-line no-control-regex
1268 if (/[\u0000-\u001f\u007f]/.test(m.id)) {
1269 return 'That id has a control character in it. An id is a handle, not data: use letters, digits and punctuation.';
1270 }
1271 if (!Array.isArray(m.argv) || !m.argv.length || !m.argv.every((a) => typeof a === 'string')) {
1272 return 'exec needs argv: the program and its arguments, as an array of strings. It is never a shell string -- there is no shell to interpret one.';
1273 }
1274 // eslint-disable-next-line no-control-regex
1275 if (m.argv.some((a) => a.indexOf('\u0000') >= 0)) {
1276 return 'An argument contains a NUL byte, which no program can be given. Whatever built that argument is broken.';
1277 }
1278 if (typeof m.cwd !== 'string' || !m.cwd) {
1279 return 'exec needs cwd, an absolute working directory inside the fence.';
1280 }
1281 if (!absolute(m.cwd) || segments(m.cwd).indexOf('..') >= 0) {
1282 return `The working directory "${m.cwd}" is not an absolute path without ".." in it. The hand does not guess `
1283 + `what a relative path is relative to, and a ".." is a way out of whatever it is written under.`;
1284 }
1285 if (typeof m.timeout_ms !== 'number' || !Number.isInteger(m.timeout_ms)
1286 || m.timeout_ms <= 0 || m.timeout_ms > TIMEOUT_MAX) {
1287 return `exec needs timeout_ms: a whole number of milliseconds between 1 and ${TIMEOUT_MAX}. A command with no `
1288 + `wall-clock limit is one nothing ever takes back.`;
1289 }
1290 if (m.capture !== undefined && ['both', 'out', 'err', 'none'].indexOf(m.capture) < 0) {
1291 return 'capture must be "both", "out", "err" or "none".';
1292 }
1293 if (m.stdin !== undefined && m.stdin !== null && typeof m.stdin !== 'string') {
1294 return 'stdin must be text, or null for a command that reads none.';
1295 }
1296
1297 const env = wrongEnv(m.env);
1298 if (env) return env;
1299
1300 return wrongFence(m.fence, m.cwd, m.toolkits);
1301 }
1302
1303 /// What is wrong with a request to change one file.
1304 ///
1305 /// The same shape of check as `wrongExec`, and deliberately no more: there is no argv,
1306 /// no environment and no shell here, so the only things this end can be sure about are
1307 /// the id, the operation's name, the absoluteness of the paths and the fence. What may
1308 /// be READ or WRITTEN is not this end's question at all -- it is the kernel's, one
1309 /// process further on, from the same plan a command's fence is built from.
1310 function wrongFile(m) {
1311 if (!m.id || typeof m.id !== 'string') {
1312 return 'Every file request needs an id, which the answer about it is tagged with.';
1313 }
1314 if (m.id.length > ID_MAX) {
1315 return `That id is ${m.id.length} characters, over the ${ID_MAX} the hand carries. Use a short handle.`;
1316 }
1317 // eslint-disable-next-line no-control-regex
1318 if (/[\u0000-\u001f\u007f]/.test(m.id)) {
1319 return 'That id has a control character in it. An id is a handle, not data: use letters, digits and punctuation.';
1320 }
1321 // A CLOSED SET, checked here as well as at the host. A word outside it reaches the
1322 // host as a decode error naming a field, which reads as the hand being broken
1323 // rather than as the request being wrong.
1324 if (['read', 'write', 'edit', 'move', 'list', 'mkdir', 'search', 'glob'].indexOf(m.op) < 0) {
1325 return `A file request must name one of read, write, edit, move, list, mkdir, search or glob; this one named ${JSON.stringify(m.op)}.`;
1326 }
1327 // EVERY path, and a walk carries a list of them. Checking `path` alone would leave
1328 // the starts of a search unvetted, which is the half that decides where it looks.
1329 const paths = [m.path, m.to, m.base].concat(Array.isArray(m.paths) ? m.paths : []);
1330 for (const p of paths) {
1331 if (p === undefined || p === null) continue;
1332 if (typeof p !== 'string' || !p) {
1333 return 'A file request\'s paths must each be a path.';
1334 }
1335 if (!absolute(p) || segments(p).indexOf('..') >= 0) {
1336 return `The path "${p}" is not an absolute path without ".." in it. The hand does not guess `
1337 + `what a relative path is relative to, and a ".." is a way out of whatever it is written under.`;
1338 }
1339 }
1340 if ((m.op === 'search' || m.op === 'glob')) {
1341 if (typeof m.query !== 'string' || !m.query) {
1342 return `A ${m.op} needs a pattern in "query".`;
1343 }
1344 if (!Array.isArray(m.paths) || !m.paths.length) {
1345 return `A ${m.op} needs "paths": where to start. A walk with nowhere to start would `
1346 + 'look at nothing and answer as though it had looked everywhere.';
1347 }
1348 if (m.skip !== undefined && (!Array.isArray(m.skip) || !m.skip.every((d) => typeof d === 'string'))) {
1349 return 'A walk\'s "skip" is the directory NAMES to pass over, as an array of strings.';
1350 }
1351 if (!Number.isInteger(m.budget) || m.budget < 1) {
1352 return 'A walk needs "budget": how many directory entries it may look at. A walk with no '
1353 + 'ceiling is a walk that never comes back.';
1354 }
1355 }
1356 if (typeof m.cwd !== 'string' || !m.cwd) {
1357 return 'A file request needs cwd, an absolute working directory inside the fence.';
1358 }
1359 if (!absolute(m.cwd) || segments(m.cwd).indexOf('..') >= 0) {
1360 return `The working directory "${m.cwd}" is not an absolute path without ".." in it.`;
1361 }
1362 for (const k of ['text', 'text2']) {
1363 if (m[k] !== undefined && m[k] !== null && typeof m[k] !== 'string') {
1364 return `A file request's ${k} must be text.`;
1365 }
1366 }
1367
1368 return wrongFence(m.fence, m.cwd, m.toolkits);
1369 }
1370
1371 /// What is wrong with a request to run a verifier.
1372 ///
1373 /// Shorter than `wrongExec` because there is far less to be wrong with,
1374 /// and that IS the security argument for this message existing rather
1375 /// than being an exec with a convention attached. A verify carries no
1376 /// argv, no cwd, no env and no fence: it carries a NAME the hand looks up
1377 /// in its own granted `dev/` directory, and at most a BREAK the hand
1378 /// looks up in that file's own source. There is nothing here for a page
1379 /// to turn into a program or a path, so there is nothing here for this
1380 /// second line to have to defend.
1381 ///
1382 /// What it does check is the shape of the two selectors, so that a
1383 /// malformed one becomes a sentence rather than a dropped connection.
1384 function wrongVerify(m) {
1385 if (!m.id || typeof m.id !== 'string') {
1386 return 'Every verify needs an id, which every answer about it is tagged with.';
1387 }
1388 if (m.id.length > ID_MAX) {
1389 return `That id is ${m.id.length} characters, over the ${ID_MAX} the hand carries. Use a short handle.`;
1390 }
1391 // eslint-disable-next-line no-control-regex
1392 if (/[\u0000-\u001f\u007f]/.test(m.id)) {
1393 return 'That id has a control character in it. An id is a handle, not data.';
1394 }
1395 if (typeof m.name !== 'string' || !NAME.test(m.name)) {
1396 return 'verify needs a name: the verifier\'s short name, lower-case letters, digits and underscores -- '
1397 + '"graph" for dev/verify_graph.mjs. It is a NAME and not a path or a command line, and the hand '
1398 + 'looks it up in the folder it was granted.';
1399 }
1400 if (['all', 'one', 'none'].indexOf(m.breaks) < 0) {
1401 return 'verify needs breaks: "all" to run every break the verifier declares, "one" with a "break" naming '
1402 + 'a declared break, or "none" for a clean run whose result proves nothing and says so.';
1403 }
1404 if (m.breaks === 'one' && (typeof m.break !== 'string' || !NAME.test(m.break))) {
1405 return 'A verify asking for one break did not name a usable one. A break is lower-case letters, digits '
1406 + 'and underscores, and it has to be one the verifier itself declares.';
1407 }
1408 if (typeof m.timeout_ms !== 'number' || !Number.isInteger(m.timeout_ms)
1409 || m.timeout_ms <= 0 || m.timeout_ms > TIMEOUT_MAX) {
1410 return `verify needs timeout_ms: a whole number of milliseconds between 1 and ${TIMEOUT_MAX}, covering `
1411 + `the WHOLE sequence -- the clean run and every break after it.`;
1412 }
1413 return null;
1414 }
1415
1416 /// The one alphabet a verifier's name and a break's name are spelled in.
1417 ///
1418 /// Deliberately narrow: no dot, so ".." cannot be written; no slash; no
1419 /// dash, so nothing can begin with one and be read as an option. The hand
1420 /// applies the same rule again -- this is a second line, never the only one.
1421 const NAME = /^[a-z0-9_]{1,64}$/;
1422
1423 /// Base64, strictly: the alphabet, correct padding, whole quanta. The hand
1424 /// rejects anything else outright, so catching it here turns a dropped
1425 /// connection into a sentence.
1426 const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1427
1428 /// What is wrong with a request to open a terminal.
1429 ///
1430 /// The same vetting an exec gets, because it is the same act -- a real
1431 /// program, on the user's machine, inside a fence this extension did not
1432 /// choose. Two differences only: there is no timeout, because a terminal
1433 /// lives until it is closed, and there is a size, because the kernel has to
1434 /// tell the program how big its screen is.
1435 function wrongOpen(m) {
1436 if (!m.id || typeof m.id !== 'string') {
1437 return 'Every terminal needs an id, which every answer about it is tagged with.';
1438 }
1439 if (m.id.length > ID_MAX) {
1440 return `That id is ${m.id.length} characters, over the ${ID_MAX} the hand carries.`;
1441 }
1442 // eslint-disable-next-line no-control-regex
1443 if (/[\u0000-\u001f\u007f]/.test(m.id)) {
1444 return 'That id has a control character in it. An id is a handle, not data.';
1445 }
1446 if (!Array.isArray(m.argv) || !m.argv.length || !m.argv.every((a) => typeof a === 'string')) {
1447 return 'open needs argv: the program and its arguments, as an array of strings. A shell is a perfectly '
1448 + 'ordinary thing to put in argv[0] here -- what it is never is a single string to be interpreted.';
1449 }
1450 // eslint-disable-next-line no-control-regex
1451 if (m.argv.some((a) => a.indexOf('\u0000') >= 0)) {
1452 return 'An argument contains a NUL byte, which no program can be given.';
1453 }
1454 if (typeof m.cwd !== 'string' || !m.cwd) {
1455 return 'open needs cwd, an absolute working directory inside the fence.';
1456 }
1457 if (!absolute(m.cwd) || segments(m.cwd).indexOf('..') >= 0) {
1458 return `The working directory "${m.cwd}" is not an absolute path without ".." in it.`;
1459 }
1460 if (!m.size || !Number.isInteger(m.size.cols) || !Number.isInteger(m.size.rows)
1461 || m.size.cols < 1 || m.size.rows < 1 || m.size.cols > 2000 || m.size.rows > 2000) {
1462 return 'open needs size.cols and size.rows as whole numbers of cells, each between 1 and 2000. '
1463 + 'A program asks the kernel how big its screen is, and a wrong answer draws a wrong screen.';
1464 }
1465
1466 const env = wrongEnv(m.env);
1467 if (env) return env;
1468
1469 return wrongFence(m.fence, m.cwd, m.toolkits, true);
1470 }
1471
1472 /// What is wrong with an exec's environment.
1473 ///
1474 /// The hand is adding its own screen for this and that one is the durable
1475 /// answer; this is the one that can be made today, on the near side of
1476 /// the boundary, where the page composing the request sits.
1477 function wrongEnv(env) {
1478 if (env === undefined || env === null) return null;
1479 if (!Array.isArray(env)) {
1480 return 'env must be a list of [name, value] pairs. The command\'s environment is given explicitly -- it never inherits the browser\'s.';
1481 }
1482 for (const pair of env) {
1483 if (!Array.isArray(pair) || pair.length !== 2
1484 || typeof pair[0] !== 'string' || typeof pair[1] !== 'string') {
1485 return 'Every env entry is a [name, value] pair of two strings.';
1486 }
1487 if (!ENV_NAME.test(pair[0])) {
1488 return `"${pair[0]}" is not a usable environment variable name. Names are letters, digits and underscores, and do not start with a digit.`;
1489 }
1490 if (ENV_FORBIDDEN.test(pair[0])) {
1491 return `The environment variable "${pair[0]}" decides what code a program loads before its own first line runs, so it `
1492 + `is not one a command may be given here. Whatever it was for, do it in the command itself.`;
1493 }
1494 }
1495 return null;
1496 }
1497
1498 /// What is wrong with an exec's fence.
1499 ///
1500 /// # Arguments
1501 /// * `f` - The `fence` the page sent, if it sent one.
1502 /// * `cwd` - The working directory, which has to be inside it.
1503 function wrongFence(f, cwd, kits, terminal) {
1504 if (!f || typeof f !== 'object' || Array.isArray(f)) {
1505 return 'exec needs a fence saying what the command may touch: {rw, ro, deny, net}. A command with no fence is a command '
1506 + 'with no compartment, and this hand does not run one.';
1507 }
1508 if (typeof f.net !== 'boolean') {
1509 return 'The fence needs net: true or false, saying whether the command may reach the network at all.';
1510 }
1511 // Absent means no toolchain was granted, which is the ordinary case and the safe one.
1512 // Present and not a list of names is a caller saying something this end cannot read.
1513 if (kits !== undefined && kits !== null
1514 && (!Array.isArray(kits) || !kits.every((k) => typeof k === 'string'))) {
1515 return 'toolkits must be a list of toolchain names the user granted, such as ["rust"], or left out '
1516 + 'where none was. It is never derived from the program being run.';
1517 }
1518 for (const field of ['rw', 'ro', 'deny']) {
1519 const list = f[field];
1520 if (!Array.isArray(list) || !list.every((p) => typeof p === 'string')) {
1521 return `The fence's ${field} must be a list of absolute paths, even where it is empty.`;
1522 }
1523 for (const p of list) {
1524 const bad = wrongRoot(p, field, kits, terminal);
1525 if (bad) return bad;
1526 }
1527 }
1528 const roots = f.rw.concat(f.ro);
1529 if (!roots.length) {
1530 return 'That fence names no root at all, so the command could not read the directory it would run in. Say what it may work under.';
1531 }
1532 if (!roots.some((r) => under(cwd, r))) {
1533 return `The working directory "${cwd}" is outside the fence, which reaches ${roots.map((r) => `"${r}"`).join(', ')} `
1534 + `and nowhere else. Run it somewhere inside the fence, or say what you would need and let the user widen it.`;
1535 }
1536 return null;
1537 }
1538
1539 /// What is wrong with one fence root.
1540 ///
1541 /// # Arguments
1542 /// * `p` - The path as the page spelled it.
1543 /// * `field` - Which list it came from, for the sentence.
1544 /// * `kits` - The toolkit names the same request carried, which is what
1545 /// lets a root outside the grant be a toolchain rather than a mistake.
1546 function wrongRoot(p, field, kits, terminal) {
1547 if (!p) {
1548 return `The fence's ${field} has an empty path in it. An empty root is not "nothing", it is a prefix of every path on `
1549 + `the machine, so it is refused rather than interpreted.`;
1550 }
1551 if (!absolute(p)) {
1552 return `The fence root "${p}" is not an absolute path. A fence written against a relative path fences whatever the `
1553 + `hand happens to be standing in.`;
1554 }
1555 if (segments(p).indexOf('..') >= 0) {
1556 return `The fence root "${p}" contains "..", which is a way out of the folder it is written under. Name the folder itself.`;
1557 }
1558 const norm = p.replace(/\/+$/, '') || '/';
1559 if (field !== 'deny' && ROOT_FORBIDDEN.has(norm)) {
1560 return `The fence root "${p}" is the machine, or a folder the machine follows from, not a workspace. A command is run `
1561 + `inside the folders the user granted; if that is genuinely what is needed, it is a conversation to have with them.`;
1562 }
1563 // The hand knows what it granted and clamps to it; where it has said
1564 // so, this end holds the page to it as well.
1565 //
1566 // With one opening, and it is the toolchain: see `hostHome`. A root
1567 // outside the grant passes here only when the request names a toolkit
1568 // AND the root is inside the home directory the hand reported -- and
1569 // the home directory ITSELF does not pass, because `~` is not a
1570 // toolchain, it is everything the user owns.
1571 // A TERMINAL may be fenced to a folder the machine offered as a ceiling, which is
1572 // wider than the grant on purpose. The list is the hand's, arriving in its `hello`,
1573 // so this is still the page being held to something it could not choose.
1574 if (field !== 'deny' && terminal && hostCeilings.some((c) => under(p, c))) {
1575 return null;
1576 }
1577 if (field !== 'deny' && hostRoot && !under(p, hostRoot)) {
1578 const granted = Array.isArray(kits) && kits.length > 0;
1579 const inHome = hostHome && under(p, hostHome) && !under(hostHome, p);
1580 if (!granted || !inHome) {
1581 return `The fence root "${p}" is outside "${hostRoot}", which is the folder this machine's hand was granted`
1582 + (granted
1583 ? `, and is not inside the home directory a granted toolchain would sit in. `
1584 : ` and this request granted no toolchain. `)
1585 + `A command cannot be fenced to somewhere the grant does not reach.`;
1586 }
1587 }
1588 return null;
1589 }
1590
1591 // -- Wiring --------------------------------------------------------
1592
1593 /// Opens the host, having established that it may be opened at all.
1594 ///
1595 /// The host is opened BEFORE the question is put, and only where the
1596 /// question has to be put at all. That is the one way the grant window
1597 /// can say what this machine actually enforces rather than what the
1598 /// product hopes it does: `caps` arrives in the hand's `hello`, and a
1599 /// window worded before the hello is a window guessing. Nothing is RUN by
1600 /// opening it -- the exchange is a greeting -- and a machine with no hand
1601 /// installed is answered with the install sentence instead of being asked
1602 /// a question about a capability it does not have.
1603 async function begin() {
1604 try {
1605 host = chrome.runtime.connectNative(HOST_NAME);
1606 } catch (e) {
1607 // A synchronous throw is the extension's own fault -- the
1608 // permission is missing from the manifest -- not the user's.
1609 stop(`This build of Daimond Hands cannot open a native messaging host: ${(e && e.message) || e}. `
1610 + `The extension needs the "nativeMessaging" permission and has to be reloaded from chrome://extensions.`);
1611 return;
1612 }
1613
1614 host.onMessage.addListener(fromHost);
1615 host.onDisconnect.addListener(hostGone);
1616 relays.add(self);
1617
1618 // Asked on every connection, granted or not. The answer carries the
1619 // folder the hand says its grant covers, and a fence is checked
1620 // against that -- so a relay that skipped the greeting when it had
1621 // nothing to ask the user would be the one relay with nothing to
1622 // check the fence against. The page's own messages wait in the
1623 // outbox meanwhile, exactly as they wait for the grant window.
1624 const said = await capabilities();
1625 if (said.gone) return; // The host went; hostGone has said why.
1626
1627 if (!(await granted(origin))) {
1628 const allowed = await askFor(origin, said.caps);
1629 if (allowed !== true) {
1630 // Declined and dismissed are different answers, and the
1631 // daimon must be able to tell them apart: one means stop
1632 // asking.
1633 stop(allowed === DECLINED ? DECLINED_SENTENCE : DISMISSED_SENTENCE);
1634 return;
1635 }
1636 }
1637
1638 // Whatever arrived while the question was open, in the order it
1639 // arrived. `connectNative` returns a port that is usable at once,
1640 // so a failure here is the host already having gone -- which its
1641 // own disconnect handler is about to describe properly.
1642 const held = outbox;
1643 outbox = [];
1644 for (const m of held) {
1645 if (!host) break;
1646 try { host.postMessage(m); } catch (e) { break; }
1647 }
1648 }
1649
1650 /// Asks the hand what it can enforce, and waits a moment for the answer.
1651 ///
1652 /// # Returns
1653 /// `{caps}` where it answered, `{caps:null}` where it did not, and
1654 /// `{gone:true}` where the host went away while we asked.
1655 function capabilities() {
1656 return new Promise((resolve) => {
1657 let settled = false;
1658 const once = (v) => {
1659 if (settled) return;
1660 settled = true;
1661 clearTimeout(timer);
1662 capsWait = null;
1663 resolve(v);
1664 };
1665 const timer = setTimeout(() => once({ caps: null }), CAPS_MS);
1666 capsWait = once;
1667 try {
1668 host.postMessage({ t: 'hello', proto: PROTO, client: 'daimond-hands' });
1669 } catch (e) {
1670 once({ gone: true });
1671 }
1672 });
1673 }
1674
1675 /// The page has gone: a tab closed, a reload, a crash. Which of those it
1676 /// was cannot be told from here and does not have to be -- the commonest
1677 /// by far is a reload, so what it left is HELD for the length of the
1678 /// grace and stopped only when nothing comes back for it.
1679 function pageGone() {
1680 park();
1681 }
1682
1683 page.onMessage.addListener(fromPage);
1684 page.onDisconnect.addListener(pageGone);
1685
1686 begin();
1687 return self;
1688 }
1689
1690 /// Starts or stops the keep-alive according to whether anything is running.
1691 ///
1692 /// A connected port already resets the worker's idle timer, but a command
1693 /// that prints nothing for minutes at a time sends no messages to reset it
1694 /// with. This is the trivial periodic call that keeps the worker resident;
1695 /// it does nothing else and stops the moment the last run ends.
1696 function breathe() {
1697 let busy = false;
1698 for (const r of relays) if (r.busy && r.busy()) { busy = true; break; }
1699 if (busy && !awake) {
1700 awake = setInterval(() => { chrome.runtime.getPlatformInfo(() => {}); }, AWAKE_MS);
1701 } else if (!busy && awake) {
1702 clearInterval(awake);
1703 awake = null;
1704 }
1705 }
1706
1707 // ------------------------------------------------------------------
1708 // What the page and the popup may ask
1709 // ------------------------------------------------------------------
1710
1711 /// Where things stand, for a page that wants to know before it connects.
1712 ///
1713 /// It cannot say whether the host is INSTALLED without launching it, and
1714 /// launching it is the capability itself -- so it does not pretend to. It
1715 /// says what has been granted TO THIS ORIGIN and what is connected, and the
1716 /// page learns the rest from `hello` or from the sentence that comes back
1717 /// instead.
1718 ///
1719 /// # Arguments
1720 /// * `sender` - The `MessageSender` the broker was given, so the answer is
1721 /// about the page that asked and not about the browser.
1722 async function status(sender) {
1723 const origin = allowedOrigin(sender);
1724 return {
1725 ok: true,
1726 proto: PROTO,
1727 host: HOST_NAME,
1728 port: PORT_NAME,
1729 origin: origin,
1730 granted: origin ? await granted(origin) : false,
1731 connected: relays.size,
1732 };
1733 }
1734
1735 /// Asks for the grant from a page that would rather ask first than have a
1736 /// window appear the moment it connects.
1737 ///
1738 /// Asked this way there is no host port open, so there are no capabilities to
1739 /// word the window from and it says so. A page that simply connects gets the
1740 /// better question, because by then the hand has spoken.
1741 ///
1742 /// # Arguments
1743 /// * `sender` - The `MessageSender` the broker was given.
1744 async function request(sender) {
1745 const origin = allowedOrigin(sender);
1746 if (!origin) return { ok: false, granted: false, error: NOT_OURS };
1747 const allowed = await askFor(origin, null);
1748 if (allowed === true) return { ok: true, granted: true };
1749 return {
1750 ok: false,
1751 granted: false,
1752 error: allowed === DECLINED ? DECLINED_SENTENCE : DISMISSED_SENTENCE,
1753 };
1754 }
1755
1756 // Only the Daimond origins reach this event at all -- externally_connectable
1757 // says so -- and the sender is checked again on the way in.
1758 chrome.runtime.onConnectExternal.addListener((port) => {
1759 if (!port || port.name !== PORT_NAME) return;
1760 const origin = allowedOrigin(port.sender || {});
1761 if (!origin) {
1762 try { port.disconnect(); } catch (e) { /* already gone */ }
1763 return;
1764 }
1765 // The tab is what tells a reload from a second window. Chrome names it
1766 // on a page connection; where it does not, this is 0 and no relay is
1767 // ever parked for it, so the old behaviour stands unchanged.
1768 const sender = port.sender || {};
1769 const tab = (sender.tab && Number.isInteger(sender.tab.id)) ? sender.tab.id : 0;
1770
1771 const waiting = tab ? parked.get(tab) : null;
1772 if (waiting) {
1773 if (waiting.origin === origin) { waiting.adopt(port); return; }
1774 // The same tab at a different Daimond origin. Its runs were started
1775 // under the other origin's grant and are not this page's to have.
1776 waiting.stop('');
1777 }
1778
1779 const r = relay(port, origin, tab);
1780 // A page that came back after the grace had run out. Told once, and the
1781 // record cleared: it is news about one gap, not a standing condition.
1782 const gap = tab ? lapses.get(tab) : null;
1783 if (gap) {
1784 lapses.delete(tab);
1785 r.lapsed(gap);
1786 }
1787 });
1788
1789 globalThis.DaimondHand = {
1790 wire,
1791 status,
1792 request,
1793 revoke,
1794 granted,
1795 patterns,
1796 ours,
1797 originOfPattern,
1798 allowedOrigin,
1799 mayConnect,
1800 PATTERN,
1801 PATTERN_SEP,
1802 HOST_NAME,
1803 PORT_NAME,
1804 };
1805
1806})();