Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/ext/background.js

40.3 KiB, 1 run

created by r2519314175:869, 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 broker.
2//
3// The Daimond page is the mind. This service worker is the only thing standing
4// between it and a real tab holding a real session, so every rule that matters
5// is enforced here, not in the page and not in the model.
6//
7// It owns three things:
8// the managed tab,
9// the mode state machine ('idle' | 'agent' | 'user'),
10// the per-origin grants.
11//
12// Nothing throws across the message boundary. Every failure comes back as
13// {ok:false, error:'<plain English>'} because the model on the other side reads
14// the error and acts on it.
15//
16// Two audiences, two languages. What the DAIMON reads -- every `error` string
17// crossing the external boundary -- stays English: it is a protocol the model
18// acts on, and the model has been steered by those exact words. What the USER
19// reads -- the toolbar title, and the reason the wheel is with them, which the
20// app prints back to them -- is translated. `i18n.js` is here for the second.
21
22'use strict';
23
24// hand.js is the relay to the machine hand -- the program outside the browser
25// that runs commands. It is a separate file because it is a separate boundary:
26// this one guards a tab, that one guards the machine. It registers its own
27// long-lived port listener (output streams, and a request/response message
28// cannot carry a stream) and is handed this file's grant machinery below.
29importScripts('i18n.js', 'hand.js');
30
31const VERSION = '0.1.0';
32
33const HAND = globalThis.DaimondHand;
34
35const I = globalThis.DaimondExtI18n;
36const T = (...a) => I.t(...a);
37
38// ---------------------------------------------------------------------------
39// Constants
40// ---------------------------------------------------------------------------
41
42/// The canonical refusals from the Web panel contract, verbatim.
43const REFUSE_PRIVATE = 'You are not driving. The user is entering something private, and Daimond is not watching. Wait for them to hand back the wheel.';
44const REFUSE_NO_PAGE = 'No page is open. Call web_open first.';
45
46/// Not a refusal on principle, just a capability the user has not switched on.
47const MIRROR_OFF = 'The user has not turned on the live mirror, so the tab cannot be photographed. Work from the snapshot instead. They can turn it on from the Daimond Hands icon.';
48
49/// A click whose accessible name matches this is consequential until the user
50/// says otherwise. False positives cost one question; false negatives cost money.
51const CONSEQUENTIAL = /buy|pay|purchase|checkout|order|confirm|delete|remove|send|transfer|subscribe/i;
52
53/// Origins that exist to take a credential. We never inject on these, never
54/// snapshot them, and never photograph them -- the mode flips on arrival.
55const SSO_HOSTS = [
56 'accounts.google.com',
57 'login.microsoftonline.com',
58 'login.live.com',
59 'appleid.apple.com',
60 'signin.aws.amazon.com',
61 'login.yahoo.com',
62 'auth.openai.com',
63 'id.atlassian.com',
64];
65
66/// Suffix match, so any tenant of these identity providers counts.
67const SSO_SUFFIXES = [
68 '.okta.com',
69 '.oktapreview.com',
70 '.auth0.com',
71 '.onelogin.com',
72 '.duosecurity.com',
73 '.pingidentity.com',
74];
75
76/// Pages an extension may not script, whatever the user grants.
77const FORBIDDEN_SCHEMES = /^(chrome|chrome-extension|edge|about|devtools|view-source|file):/i;
78
79// ---------------------------------------------------------------------------
80// State
81//
82// An MV3 service worker is evicted when idle, so module scope is not storage.
83// chrome.storage.session lives in memory and never touches the disk, which is
84// the right home for "which tab is Daimond driving".
85// ---------------------------------------------------------------------------
86
87const BLANK = {
88 tabId: null,
89 windowId: null,
90 mode: 'idle', // 'idle' | 'agent' | 'user'
91 url: '',
92 title: '',
93 truce: false, // The user handed back with the login form still on screen.
94 reason: '', // Why the wheel is with the user, in canonical English.
95 reasonKey: '', // The same reason as a string key, for the user's language.
96 reasonArg: '', // Its one substitution, where it has one.
97 noMirror: false, // The user said no to the live mirror. Do not nag.
98};
99
100/// Canonical reason -> string key. The canonical English is what the state
101/// holds and what the truce is compared against, because a comparison that
102/// changed with the interface language would be a security rule with a
103/// translation bug in it. The key is only what the user is SHOWN.
104const REASON_KEY = {
105 'a password field': 'reason_password',
106 'a passkey or one-time-code prompt': 'reason_passkey_otc',
107 'a passkey prompt': 'reason_passkey',
108 'the user is typing': 'reason_typing',
109 'something private': 'reason_private',
110};
111
112/// The state patch that hands the wheel to the user, for a canonical reason.
113function because(reason) {
114 return { mode: 'user', reason, reasonKey: REASON_KEY[reason] || '', reasonArg: '' };
115}
116
117/// Why the wheel is with the user, in the user's own language.
118///
119/// The app prints this inside a sentence of its own ("I stopped at …"), so it
120/// is a noun phrase in every language, not a sentence. A reason with no key --
121/// one the content script invented -- falls through as it arrived, which is
122/// English but never blank.
123function reasonText(s) {
124 if (!s || !s.reason) return '';
125 return s.reasonKey ? T(s.reasonKey, s.reasonArg || '') : s.reason;
126}
127
128/// Photographing a tab is the one thing Chrome will not do on a per-site grant:
129/// captureVisibleTab wants <all_urls> or a user gesture on the tab itself. So the
130/// mirror is a separate, second question, asked the first time the page wants a
131/// picture -- never at install, and never bundled with a site grant.
132const ALL_URLS = '<all_urls>';
133
134/// Reads the whole state. Cheap, and always correct after an eviction.
135async function get() {
136 const got = await chrome.storage.session.get('s');
137 return Object.assign({}, BLANK, got.s || {});
138}
139
140/// Merges a patch into the state and returns the result.
141async function set(patch) {
142 const s = Object.assign(await get(), patch);
143 await chrome.storage.session.set({ s });
144 return s;
145}
146
147// ---------------------------------------------------------------------------
148// Grants
149// ---------------------------------------------------------------------------
150
151/// The match pattern for an origin, e.g. https://example.com/*
152/// Chrome ignores the port in host permissions, so this is per host, per scheme.
153/// The match pattern a site's approval grants. Chrome's `*.host` form matches
154/// the host AND all its subdomains, so approving `fireworks.ai` covers
155/// `app.fireworks.ai` too -- which is what a user means by "this site", and
156/// without which a click that crosses to the app subdomain dies for lack of
157/// permission. The expansion is only ever DOWNWARD, to subdomains of what was
158/// approved, never up to a parent, so there is no over-reach.
159function pattern(url) {
160 return `*://*.${new URL(url).hostname}/*`;
161}
162
163/// The hosts the extension holds by its own manifest: Daimond's own pages, where
164/// announce.js runs and which may speak to the broker. Chrome reports them among
165/// the permissions, but the user never granted them and cannot withdraw them --
166/// they came with the install. Listing them under "sites you have allowed" would
167/// be untrue twice over: a permission they did not give, beside a Revoke button
168/// that can do nothing.
169function ownHosts() {
170 const m = chrome.runtime.getManifest();
171 const pats = [].concat(
172 (m.externally_connectable && m.externally_connectable.matches) || [],
173 ...(m.content_scripts || []).map((cs) => cs.matches || []));
174 return new Set(pats.map(hostOfPattern).filter(Boolean));
175}
176
177/// The host part of a match pattern, e.g. `*://*.example.com/*` -> `example.com`.
178function hostOfPattern(pat) {
179 const m = /^[^:]+:\/\/(\*\.)?([^/]+)\//.exec(pat);
180 return m ? m[2] : '';
181}
182
183/// Every origin the USER has approved, as match patterns.
184///
185/// The machine hand rides in this list too, as `machine-hand`. It is not an
186/// origin and is deliberately not spelled like one, but it is a thing the user
187/// allowed and must be able to take back, and the popup is where they take
188/// things back. A grant that could not be found where every other grant is
189/// listed would be a grant nobody remembers giving.
190async function grants() {
191 const all = await chrome.permissions.getAll();
192 const own = ownHosts();
193 const list = (all.origins || []).filter((o) => !own.has(hostOfPattern(o)));
194 // One line per origin that holds it, not one line for the browser. A grant
195 // the user cannot see the extent of is a grant they cannot withdraw the
196 // right part of.
197 return list.concat(await HAND.patterns());
198}
199
200/// Has the user approved this url's origin?
201async function isGranted(url) {
202 try {
203 // Check the EXACT host being accessed. `contains` is coverage-based, so a
204 // subdomain grant (`*://*.fireworks.ai/*`) correctly answers true for the
205 // exact host (`*://app.fireworks.ai/*`) it covers.
206 return await chrome.permissions.contains({ origins: [`*://${new URL(url).hostname}/*`] });
207 } catch (e) {
208 return false;
209 }
210}
211
212/// Pending grant questions, keyed by nonce.
213const pending = new Map();
214
215/// How a question ended. Not a boolean, because "the user said no" and "nobody
216/// ever answered" call for different words -- to the user, and to the daimon,
217/// which must stop asking after the first and may ask again after the second.
218const ALLOWED = 'allowed';
219const DECLINED = 'declined';
220const DISMISSED = 'dismissed';
221
222/// The toolbar icon is the one surface that is always there. While a question is
223/// waiting it carries a mark, so a window that landed behind something is still
224/// findable -- and the popup below it says what is being asked and offers the
225/// way back. This paints the mark; it never opens anything.
226async function markPending() {
227 const q = [...pending.values()][0];
228 if (!q) {
229 chrome.action.setBadgeText({ text: '' });
230 chrome.action.setTitle({ title: 'Daimond Hands' });
231 return;
232 }
233 chrome.action.setBadgeText({ text: '?' });
234 chrome.action.setBadgeBackgroundColor({ color: '#2f6fed' });
235 await I.ready();
236 chrome.action.setTitle({
237 title: q.kind === 'mirror' ? T('action_pending_mirror')
238 : q.kind === 'hand' ? T('action_pending_hand')
239 : T('action_pending_site', q.host),
240 });
241}
242
243/// What the user is being asked, for the popup to say in its own words.
244function pendingQuestion() {
245 const q = [...pending.values()][0];
246 return q ? { kind: q.kind, host: q.host || '' } : null;
247}
248
249/// Brings the pending question's window back to the front. The popup's way out
250/// of a lost window -- and it RAISES, never creates: a second window per ask is
251/// the popup flood the mirror guard exists to prevent.
252async function raisePending() {
253 const q = [...pending.values()][0];
254 await I.ready();
255 if (!q || q.windowId == null) return { ok: false, error: T('raise_none') };
256 try {
257 await chrome.windows.update(q.windowId, { focused: true, drawAttention: true });
258 return { ok: true };
259 } catch (e) {
260 return { ok: false, error: T('raise_gone') };
261 }
262}
263
264/// Puts the question to the user in a window of our own, and waits.
265///
266/// chrome.permissions.request needs a user gesture, and a message from a web
267/// page is not one. So the extension asks in its own page, where a click is a
268/// click. The page's `open` call simply blocks until the user has answered.
269///
270/// Resolves ALLOWED, DECLINED or DISMISSED.
271async function ask(params) {
272 const nonce = Math.random().toString(36).slice(2);
273 const q = new URLSearchParams(Object.assign({ nonce }, params));
274 // Sized for the FIRST screen, not for everything the window can say. Opening
275 // the disclosure scrolls the sheet between the brand and the buttons rather
276 // than growing the window, so the two buttons are on screen at every height
277 // and in every language -- which a 470px window full of prose was not.
278 //
279 // The height is a starting guess and nothing more: the window is created
280 // before the language it will be written in is known, and French needs
281 // 353px of sheet where Chinese needs 250. grant.js measures what it actually
282 // got and sizes the window to its own first screen, either way.
283 const W = 480, H = 470;
284
285 // Centre the grant window over the app window. A popup that Chrome drops
286 // behind the main window, or off in a corner, is a grant no one sees -- the
287 // one surface the whole flow turns on must land where the user is looking.
288 let place = {};
289 try {
290 const cur = await chrome.windows.getLastFocused();
291 if (cur && cur.width) {
292 place = {
293 left: Math.max(0, Math.round(cur.left + (cur.width - W) / 2)),
294 top: Math.max(0, Math.round(cur.top + (cur.height - H) / 2)),
295 };
296 }
297 } catch (e) { /* fall back to Chrome's own placement */ }
298
299 const win = await chrome.windows.create(Object.assign({
300 url: chrome.runtime.getURL(`grant.html?${q}`),
301 type: 'popup',
302 focused: true,
303 width: W,
304 height: H,
305 }, place));
306
307 // `focused: true` on create is not always honoured, so raise it again and
308 // flash it, making sure it comes to the front rather than hiding.
309 try { await chrome.windows.update(win.id, { focused: true, drawAttention: true }); }
310 catch (e) { /* best effort */ }
311
312 return await new Promise((resolve) => {
313 pending.set(nonce, { resolve, windowId: win.id, kind: params.kind || 'site', host: params.host || '' });
314 markPending();
315 });
316}
317
318/// May Daimond operate this site?
319async function askGrant(url) {
320 return await ask({ kind: 'site', host: new URL(url).hostname, pattern: pattern(url) });
321}
322
323// The relay asks the same question, through the same window, with the same
324// three answers. It is handed the machinery rather than reaching for it: both
325// scripts share one worker scope, so `ask` would be visible to it by accident
326// of load order, and a dependency that works by accident breaks silently when
327// the order changes.
328HAND.wire({ ask, ALLOWED, DECLINED });
329
330/// May Daimond photograph the tab, so the panel can mirror it?
331///
332/// At most ONE mirror window is ever open. The panel polls `frame` on a timer,
333/// and without this guard each poll that arrived before the user answered would
334/// open another window -- a popup every second or so, faster than anyone can
335/// dismiss. While a request is pending, later callers share its answer.
336let mirrorAsk = null;
337async function askMirror() {
338 if (!mirrorAsk) {
339 mirrorAsk = ask({ kind: 'mirror', pattern: ALL_URLS }).finally(() => { mirrorAsk = null; });
340 }
341 return await mirrorAsk;
342}
343
344/// The grant window answered.
345function settleGrant(nonce, answer) {
346 const p = pending.get(nonce);
347 if (!p) return;
348 pending.delete(nonce);
349 markPending();
350 p.resolve(answer === ALLOWED ? ALLOWED : DECLINED);
351 if (p.windowId != null) chrome.windows.remove(p.windowId).catch(() => {});
352}
353
354// The pending map lives in module scope, so an evicted service worker forgets
355// every question it was waiting on -- but the toolbar mark it painted would
356// outlive it. Clear it on every wake: nothing is pending in a worker that has
357// just started.
358markPending();
359
360/// If the user grants something, they have plainly changed their mind about the
361/// mirror, so let it be asked for again.
362chrome.permissions.onAdded.addListener(() => {
363 set({ noMirror: false });
364});
365
366/// A window that goes away without answering was DISMISSED, not refused. The
367/// difference is the whole point: a user who never saw the question has not said
368/// no, and telling the daimon they did would end an errand they still want run.
369chrome.windows.onRemoved.addListener((windowId) => {
370 for (const [nonce, p] of pending) {
371 if (p.windowId === windowId) {
372 pending.delete(nonce);
373 markPending();
374 p.resolve(DISMISSED);
375 }
376 }
377});
378
379// ---------------------------------------------------------------------------
380// The tab
381// ---------------------------------------------------------------------------
382
383/// Does the managed tab still exist?
384async function alive(tabId) {
385 if (tabId == null) return false;
386 try {
387 await chrome.tabs.get(tabId);
388 return true;
389 } catch (e) {
390 return false;
391 }
392}
393
394/// Waits for a tab to finish loading, or gives up quietly.
395async function settled(tabId, ms = 10000) {
396 const until = Date.now() + ms;
397 while (Date.now() < until) {
398 let tab;
399 try {
400 tab = await chrome.tabs.get(tabId);
401 } catch (e) {
402 return null;
403 }
404 if (tab.status === 'complete') return tab;
405 await sleep(80);
406 }
407 try {
408 return await chrome.tabs.get(tabId);
409 } catch (e) {
410 return null;
411 }
412}
413
414function sleep(ms) {
415 return new Promise((r) => setTimeout(r, ms));
416}
417
418/// Waits for what an action did.
419///
420/// A click on a submit button returns before the browser has even begun to
421/// navigate, so asking the tab where it is straight afterwards gets the old
422/// answer. This gives the page a grace period to start moving; once it has
423/// started, it waits for it to arrive.
424async function settledAfter(tabId, before, grace = 1200, limit = 12000) {
425 const start = Date.now();
426 let moved = false;
427
428 while (Date.now() - start < limit) {
429 let tab;
430 try {
431 tab = await chrome.tabs.get(tabId);
432 } catch (e) {
433 return null;
434 }
435 if (tab.status === 'loading' || (tab.url && tab.url !== before)) moved = true;
436 if (moved && tab.status === 'complete' && tab.url !== before) return tab;
437 if (!moved && Date.now() - start > grace) return tab; // The click did not navigate.
438 if (moved && tab.status === 'complete' && Date.now() - start > grace) return tab;
439 await sleep(60);
440 }
441 try {
442 return await chrome.tabs.get(tabId);
443 } catch (e) {
444 return null;
445 }
446}
447
448/// Is this url an identity provider, i.e. a place a password gets typed?
449function isSSO(url) {
450 let h;
451 try {
452 h = new URL(url).hostname.toLowerCase();
453 } catch (e) {
454 return false;
455 }
456 if (SSO_HOSTS.includes(h)) return true;
457 return SSO_SUFFIXES.some((sfx) => h.endsWith(sfx));
458}
459
460// ---------------------------------------------------------------------------
461// The content script
462// ---------------------------------------------------------------------------
463
464/// Puts the hands on the page and arms the login detector.
465///
466/// Returns what the detector saw, so the caller can flip the mode *before* it
467/// decides whether to answer. Returns null when the page cannot be scripted.
468async function arm(tabId, truce) {
469 try {
470 // The isolated world survives between calls but dies on navigation,
471 // which is exactly the lifetime a ref should have. Re-injecting is
472 // cheap and idempotent.
473 await chrome.scripting.executeScript({
474 target: { tabId },
475 world: 'MAIN',
476 func: shimWebAuthn,
477 });
478 await chrome.scripting.executeScript({
479 target: { tabId },
480 files: ['content.js'],
481 });
482 const [res] = await chrome.scripting.executeScript({
483 target: { tabId },
484 func: (t) => globalThis.__daimond.arm(t),
485 args: [!!truce],
486 });
487 return res && res.result ? res.result : null;
488 } catch (e) {
489 return null;
490 }
491}
492
493/// Takes the hands off: observers disconnected, refs dropped, listeners removed.
494async function disarm(tabId) {
495 try {
496 await chrome.scripting.executeScript({
497 target: { tabId },
498 func: () => globalThis.__daimond && globalThis.__daimond.detach(),
499 });
500 } catch (e) {
501 // The page is already gone, or was never ours. Either way, detached.
502 }
503}
504
505/// Runs one command in the page.
506async function call(tabId, cmd, args) {
507 let out;
508 try {
509 out = await chrome.scripting.executeScript({
510 target: { tabId },
511 func: (c, a) => globalThis.__daimond.handle(c, a),
512 args: [cmd, args || {}],
513 });
514 } catch (e) {
515 // executeScript rejects when the page NAVIGATES and tears down the content
516 // script's context mid-call. For a click or a submit that is not a failure
517 // -- it is exactly what success looks like -- so flag it for the caller to
518 // interpret rather than throwing.
519 return { ok: false, error: String((e && e.message) || e), contextLost: true };
520 }
521 const res = out && out[0];
522 if (!res || res.result === undefined) {
523 return { ok: false, error: 'The page did not answer. It may have navigated. Take a fresh snapshot.', contextLost: true };
524 }
525 return res.result;
526}
527
528/// Injected into the page's own world. Wraps the WebAuthn entry points so that
529/// a passkey prompt announces itself. It reads no arguments and keeps no data:
530/// it raises a DOM event and gets out of the way.
531function shimWebAuthn() {
532 if (window.__daimondShim) return;
533 window.__daimondShim = true;
534 const cred = navigator.credentials;
535 if (!cred) return;
536 const wrap = (name) => {
537 const orig = cred[name];
538 if (typeof orig !== 'function') return;
539 cred[name] = function (...args) {
540 try {
541 document.dispatchEvent(new CustomEvent('__daimond_private', {
542 detail: { reason: 'a passkey prompt' },
543 }));
544 } catch (e) {
545 // Never break the page we are guests on.
546 }
547 return orig.apply(this, args);
548 };
549 };
550 wrap('get');
551 wrap('create');
552}
553
554// ---------------------------------------------------------------------------
555// The mode machine
556// ---------------------------------------------------------------------------
557
558/// Brings the state up to date with the tab, and flips to 'user' if the page is
559/// asking for a credential. Call this before answering anything.
560///
561/// Returns the fresh state.
562async function sync() {
563 let s = await get();
564 if (!(await alive(s.tabId))) {
565 return await set(Object.assign({}, BLANK));
566 }
567
568 const tab = await chrome.tabs.get(s.tabId);
569 s = await set({ url: tab.url || '', title: tab.title || '', windowId: tab.windowId });
570
571 // An identity provider is a login by definition. Do not even inject.
572 if (isSSO(s.url)) {
573 if (s.mode !== 'user') {
574 const h = new URL(s.url).hostname;
575 s = await set(Object.assign(because('the sign-in page for ' + h),
576 { reasonKey: 'reason_sso', reasonArg: h }));
577 await showResumeOverlay(s.tabId);
578 }
579 return s;
580 }
581
582 // The user has the wheel. We do not touch the page at all.
583 if (s.mode === 'user') return s;
584
585 if (FORBIDDEN_SCHEMES.test(s.url)) return s;
586
587 const seen = await arm(s.tabId, s.truce);
588 if (seen && seen.private) {
589 s = await set(because(seen.reason));
590 await disarm(s.tabId);
591 await showResumeOverlay(s.tabId);
592 }
593 return s;
594}
595
596/// The wheel goes to the user. Called by the page's own detectors and by the
597/// keystroke listener. It is one-way: only an explicit takeover comes back.
598async function toUser(reason) {
599 const s = await get();
600 if (s.mode === 'user') return;
601 await set(because(reason));
602 if (s.tabId != null) { await disarm(s.tabId); await showResumeOverlay(s.tabId); }
603}
604
605/// Render the "Resume Daimond" button inside the managed tab -- a trusted
606/// gesture surface the web page cannot forge (it lives in a closed shadow root
607/// and speaks on the internal channel). Best-effort: an SSO tab we lack
608/// permission to script simply shows nothing, and the extension popup remains a
609/// second, always-available way back.
610async function showResumeOverlay(tabId) {
611 try {
612 await I.ready();
613 await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] });
614 // The label is carried in rather than looked up in the page: a content
615 // script would have to be handed the whole `_locales` tree to read it
616 // for itself, and the hands have no business holding the strings.
617 await chrome.scripting.executeScript({
618 target: { tabId },
619 func: (label) => globalThis.__daimond && globalThis.__daimond.handle('showResume', { label }),
620 args: [T('resume_button')],
621 });
622 } catch (e) { /* tab gone, or a page we may not script */ }
623}
624async function hideResumeOverlay(tabId) {
625 try {
626 await chrome.scripting.executeScript({
627 target: { tabId },
628 func: () => globalThis.__daimond && globalThis.__daimond.handle('hideResume', {}),
629 });
630 } catch (e) { /* nothing to hide */ }
631}
632
633/// The wheel comes back to the agent. This is the ONLY way out of user mode,
634/// and it is reachable only from a trusted surface: the in-tab resume overlay
635/// or the extension popup, never the web page.
636async function doTakeover() {
637 const s = await get();
638 if (s.tabId == null || !(await alive(s.tabId))) {
639 return { ok: false, error: REFUSE_NO_PAGE };
640 }
641 await hideResumeOverlay(s.tabId);
642 // The user has said, with a gesture of their own, that Daimond may look
643 // again. Honour it even if the login form is still on the page: the truce
644 // stops the detector from snatching the wheel straight back, and the
645 // snapshot still refuses to serialise any password either way.
646 const after = await set({ mode: 'agent', truce: true, reason: '', reasonKey: '', reasonArg: '' });
647 await arm(after.tabId, true);
648 return { ok: true, mode: 'agent', url: after.url, title: after.title };
649}
650
651/// The content script speaks to us here: about privacy, and about the user's
652/// own gesture to resume. Both are trusted because they arrive from OUR managed
653/// tab (`sender.tab.id === s.tabId`), which the web page cannot impersonate.
654chrome.runtime.onMessage.addListener((msg, sender, respond) => {
655 // Answer ONLY what a content script sends. The extension's own pages speak on
656 // this same channel and are answered by the listener at the foot of the file;
657 // Chrome hands a message to every listener and keeps the FIRST answer, so a
658 // listener that answers everything steals theirs. Sorting by the shape of the
659 // sender is what went wrong before: a popup has no tab, but the grant window
660 // DOES -- it is a tab in a window of its own -- so one listener swallowed the
661 // popup's questions and the other ignored the grant window's answer. The
662 // message type says which channel a message belongs to; the sender says
663 // whether it may be trusted. Both are checked, and separately.
664 if (!msg || !['private', 'typing', 'resume'].includes(msg.type)) return false;
665 if (!sender.tab) { respond({ ok: false }); return false; }
666 (async () => {
667 const s = await get();
668 if (sender.tab.id !== s.tabId) { respond({ ok: false }); return; } // Not our tab.
669 if (msg && msg.type === 'private') {
670 if (s.truce && msg.reason === 'a password field') { respond({ ok: true }); return; }
671 await toUser(msg.reason || 'something private');
672 respond({ ok: true });
673 } else if (msg && msg.type === 'typing') {
674 await toUser('the user is typing');
675 respond({ ok: true });
676 } else if (msg && msg.type === 'resume') {
677 // The resume overlay lives in this tab's shadow root; a click on it is
678 // a trusted gesture the page cannot make. This is a real hand-back.
679 respond(await doTakeover());
680 } else {
681 respond({ ok: false });
682 }
683 })();
684 return true; // The answer may be async (resume).
685});
686
687/// A navigation ends the truce and invalidates every ref.
688chrome.tabs.onUpdated.addListener(async (tabId, info) => {
689 const s = await get();
690 if (tabId !== s.tabId) return;
691 if (info.url && info.url !== s.url) {
692 await set({ url: info.url, truce: false });
693 }
694 if (info.status === 'complete') {
695 await sync();
696 }
697});
698
699chrome.tabs.onRemoved.addListener(async (tabId) => {
700 const s = await get();
701 if (tabId === s.tabId) await set(Object.assign({}, BLANK));
702});
703
704// ---------------------------------------------------------------------------
705// Consequence
706// ---------------------------------------------------------------------------
707
708/// Reads a click before it happens. Returns a plain-English description of what
709/// makes it consequential, or null when it is ordinary.
710async function consequence(d) {
711 const name = (d.name || '').trim();
712
713 if (name && CONSEQUENTIAL.test(name)) {
714 const where = d.formAction ? ` It submits a form to ${originOf(d.formAction)}.` : '';
715 return `Click "${name}".${where} That name suggests it spends money, sends something, or cannot be undone.`;
716 }
717
718 if (d.isSubmit && (d.formMethod || '').toLowerCase() === 'post') {
719 const dest = d.formAction || d.pageUrl;
720 if (!(await isGranted(dest))) {
721 return `Click "${name || d.role}", which POSTs a form to ${originOf(dest)}. The user has not approved that origin.`;
722 }
723 }
724
725 return null;
726}
727
728function originOf(url) {
729 try {
730 return new URL(url).origin;
731 } catch (e) {
732 return url;
733 }
734}
735
736// ---------------------------------------------------------------------------
737// The protocol
738// ---------------------------------------------------------------------------
739
740/// Every command that needs a live tab in agent mode passes through here.
741async function driving() {
742 const s = await sync();
743 if (s.tabId == null) return { err: { ok: false, error: REFUSE_NO_PAGE } };
744 if (s.mode !== 'agent') return { err: { ok: false, error: REFUSE_PRIVATE, mode: s.mode } };
745 return { s };
746}
747
748const HANDLERS = {
749
750 async ping() {
751 return { ok: true, version: VERSION };
752 },
753
754 async open(msg) {
755 if (!msg.url) return { ok: false, error: 'open needs a url.' };
756
757 let url;
758 try {
759 url = new URL(msg.url).href;
760 } catch (e) {
761 return { ok: false, error: `That is not a url I can open: ${msg.url}` };
762 }
763 if (!/^https?:/.test(url)) {
764 return { ok: false, error: 'Daimond Hands only opens http and https pages.' };
765 }
766
767 if (!(await isGranted(url))) {
768 const answer = await askGrant(url);
769 // Do NOT trust how the grant window closed. Chrome's own permission
770 // prompt takes focus and can dismiss the window at the very instant the
771 // user grants the permission -- which the window-close handler would
772 // read as a decline, refusing a site the user just allowed (and then
773 // "try again" works, because it really is granted now). So ask the
774 // permission system itself, which is the only truth.
775 //
776 // The answer decides only the WORDS of the refusal, never whether it is
777 // one. A decline is final and the daimon must stop asking; a window
778 // closed unseen is not an answer at all, and saying otherwise would
779 // abandon an errand the user still wants run.
780 if (!(await isGranted(url))) {
781 const host = new URL(url).hostname;
782 return {
783 ok: false,
784 error: answer === DECLINED
785 ? `The user declined: Daimond may not operate ${host}. Do not ask for it again. Tell them what you wanted to do there, or read the page with web_fetch instead.`
786 : `The approval window for ${host} was closed before it was answered, so the site is not approved. The user may not have seen it -- the Daimond Hands icon carries the question until it is answered. Ask them to allow it and try web_open again, or read the page with web_fetch instead.`,
787 };
788 }
789 }
790
791 let s = await get();
792 let tab = null;
793
794 if (await alive(s.tabId)) {
795 tab = await chrome.tabs.update(s.tabId, { url, active: true });
796 } else {
797 const win = await chrome.windows.create({
798 url,
799 focused: true,
800 width: 1180,
801 height: 860,
802 });
803 tab = win.tabs[0];
804 }
805
806 await set({ tabId: tab.id, windowId: tab.windowId, mode: 'agent', truce: false, reason: '' });
807 const done = await settled(tab.id);
808 s = await sync();
809
810 return {
811 ok: true,
812 tabId: tab.id,
813 url: s.url,
814 title: s.title || (done && done.title) || '',
815 mode: s.mode,
816 };
817 },
818
819 async close() {
820 const s = await get();
821 if (s.tabId != null && (await alive(s.tabId))) {
822 try {
823 await chrome.tabs.remove(s.tabId);
824 } catch (e) {
825 // Already gone.
826 }
827 }
828 await set(Object.assign({}, BLANK));
829 return { ok: true };
830 },
831
832 async status() {
833 const s = await sync();
834 await I.ready();
835 return {
836 ok: true,
837 url: s.url,
838 title: s.title,
839 mode: s.mode,
840 // The app prints this one back to the user, inside a sentence of its
841 // own, so it is the one field of the protocol that is translated.
842 reason: s.mode === 'user' ? reasonText(s) : '',
843 granted: await grants(),
844 };
845 },
846
847 async snapshot() {
848 const { err, s } = await driving();
849 if (err) return err;
850
851 const res = await call(s.tabId, 'snapshot', {});
852 if (!res.ok) return res;
853
854 return {
855 ok: true,
856 url: s.url,
857 title: s.title,
858 nodes: res.nodes,
859 truncated: res.truncated,
860 total: res.total,
861 };
862 },
863
864 /// The rendered text of the page Daimond is driving -- JavaScript and all,
865 /// no node budget, no dependence on the site's accessibility markup.
866 async read() {
867 const { err, s } = await driving();
868 if (err) return err;
869 const res = await call(s.tabId, 'read', {});
870 if (!res.ok) return res;
871 return { ok: true, url: s.url, title: s.title, text: res.text, chars: res.chars, truncated: res.truncated };
872 },
873
874 async click(msg) {
875 const { err, s } = await driving();
876 if (err) return err;
877 if (!Number.isInteger(msg.ref)) return { ok: false, error: 'click needs a ref from the last snapshot.' };
878
879 const d = await call(s.tabId, 'describe', { ref: msg.ref });
880 if (!d.ok) return d;
881
882 if (!msg.confirmed) {
883 const why = await consequence(d);
884 if (why) {
885 return { ok: false, error: `CONFIRM: ${why}`, confirm: true };
886 }
887 }
888
889 const before = s.url;
890 const res = await call(s.tabId, 'click', { ref: msg.ref });
891 // A genuine failure is reported; a lost context is a NAVIGATION -- the
892 // click worked and took the page elsewhere -- confirmed by settling.
893 if (!res.ok && !res.contextLost) return res;
894
895 const after = await settledAfter(s.tabId, before);
896 const now = await sync();
897 return { ok: true, url: (now && now.url) || (after && after.url) || before, mode: now.mode };
898 },
899
900 async type(msg) {
901 const { err, s } = await driving();
902 if (err) return err;
903 if (!Number.isInteger(msg.ref)) return { ok: false, error: 'type needs a ref from the last snapshot.' };
904 if (typeof msg.text !== 'string') return { ok: false, error: 'type needs text.' };
905
906 // Typing with submit:true presses Enter and posts the form, which is a
907 // click on that form's submit button by another name. It must pass the
908 // SAME consequence gate the click handler applies, or a checkout could be
909 // completed by choosing `type` instead of `click` -- the one verb the
910 // gate did not cover. "Do as I mean, or nothing done" cannot have a back
911 // door.
912 if (msg.submit && !msg.confirmed) {
913 const d = await call(s.tabId, 'describe', { ref: msg.ref });
914 if (d && d.ok) {
915 // Judge the submit by the BUTTON it fires, not the field it is
916 // typed into: a password field is innocent, "Complete purchase" is
917 // not, and pressing Enter in the former fires the latter.
918 const why = await consequence({
919 ...d,
920 name: d.submitName || d.name,
921 isSubmit: true,
922 });
923 if (why) return { ok: false, error: `CONFIRM: ${why}`, confirm: true };
924 }
925 }
926
927 const before = s.url;
928 const res = await call(s.tabId, 'type', { ref: msg.ref, text: msg.text, submit: !!msg.submit });
929 // As with click: a submit that navigates loses the context, which is
930 // success, not failure.
931 if (!res.ok && !res.contextLost) return res;
932
933 if (msg.submit) await settledAfter(s.tabId, before);
934 const now = await sync();
935 return { ok: true, url: (now && now.url) || before, mode: now.mode };
936 },
937
938 async scroll(msg) {
939 const { err, s } = await driving();
940 if (err) return err;
941 return await call(s.tabId, 'scroll', {
942 direction: msg.direction || 'down',
943 amount: Number(msg.amount) || 0,
944 });
945 },
946
947 async frame() {
948 const { err, s } = await driving();
949 if (err) return err;
950
951 // The mirror is a second question, and it is asked here rather than at
952 // install: Chrome will not photograph a tab on a per-site grant alone.
953 if (!(await chrome.permissions.contains({ origins: [ALL_URLS] }))) {
954 if (s.noMirror) {
955 return { ok: false, error: MIRROR_OFF };
956 }
957 const ok = await askMirror();
958 if (ok !== ALLOWED) {
959 await set({ noMirror: true });
960 return { ok: false, error: MIRROR_OFF };
961 }
962 }
963
964 try {
965 const png = await chrome.tabs.captureVisibleTab(s.windowId, { format: 'png' });
966 return { ok: true, png, url: s.url, title: s.title };
967 } catch (e) {
968 return {
969 ok: false,
970 error: `The tab could not be photographed: ${(e && e.message) || e}. It may be minimised or behind another window. Work from the snapshot instead.`,
971 };
972 }
973 },
974
975 // ── The machine hand ────────────────────────────────────────────────
976 //
977 // Three questions, and no command among them. Running something is not a
978 // message-and-answer -- output streams, and a reply that arrives once cannot
979 // carry a stream -- so `exec`, `signal` and `bye` travel on the long-lived
980 // port hand.js listens for, and only the STATE of the thing is asked here.
981 // That keeps the two shapes honestly apart: this table is for questions with
982 // one answer.
983
984 /// What the hand is, and whether it may be used. It does not claim to know
985 /// whether the host is installed: finding that out means launching it, and
986 /// launching it is the capability itself.
987 ///
988 /// All three of these are about the ORIGIN that asked, which is why each of
989 /// them is handed the sender. A page asking whether it may run commands is
990 /// not asking whether some other page may.
991 async hand_status(msg, sender) {
992 return await HAND.status(sender);
993 },
994
995 /// Ask for the grant now, rather than have a window appear the instant the
996 /// page opens its port. Same window, same three answers.
997 async hand_grant(msg, sender) {
998 return await HAND.request(sender);
999 },
1000
1001 /// Give it back. Everything running stops, because a permission that let the
1002 /// current build finish would be a promise with an asterisk on it.
1003 async hand_revoke(msg, sender) {
1004 await HAND.revoke(HAND.allowedOrigin(sender));
1005 return { ok: true, granted: false };
1006 },
1007
1008 // `takeover` is deliberately NOT here. It is the one command that must never
1009 // be reachable from the web page, because the page is driven by an agent the
1010 // page's own text may have steered, and letting it end user mode would let it
1011 // read a login form the user is filling in. It lives at `doTakeover`, reached
1012 // only from the in-tab resume overlay or the extension popup -- both trusted.
1013};
1014
1015// ---------------------------------------------------------------------------
1016// The boundary
1017// ---------------------------------------------------------------------------
1018
1019/// The page speaks to us here. externally_connectable already restricts who may
1020/// call; this checks it again, because the boundary is the whole product.
1021///
1022/// It says "checks it again" and now it does. Until 2026-08-02 the sentence was
1023/// there and the check was not: `sender` was passed to the handlers and never
1024/// read, so the second look at the boundary was a comment. Chrome's own list is
1025/// what stops a stranger today, and a re-check that is only a comment is one
1026/// that fails open the day that list is edited -- which is exactly what the dev
1027/// origins were.
1028chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
1029 (async () => {
1030 try {
1031 if (!HAND.allowedOrigin(sender)) {
1032 return respond({
1033 ok: false,
1034 error: 'Daimond Hands answers Daimond\'s own pages and no others. This message came from somewhere else.',
1035 });
1036 }
1037 if (!msg || typeof msg.cmd !== 'string') {
1038 return respond({ ok: false, error: 'Every message needs a cmd.' });
1039 }
1040 const h = HANDLERS[msg.cmd];
1041 if (!h) {
1042 return respond({ ok: false, error: `Daimond Hands does not know the command "${msg.cmd}".` });
1043 }
1044 respond(await h(msg, sender));
1045 } catch (e) {
1046 // Nothing throws across the boundary, ever.
1047 respond({ ok: false, error: `Daimond Hands failed: ${(e && e.message) || String(e)}` });
1048 }
1049 })();
1050 return true; // The answer is async.
1051});
1052
1053/// The popup, and the grant window, speak to us on the internal channel.
1054///
1055/// "Our own page" is decided by the sender's URL, not by whether it sits in a
1056/// tab. The grant window is a tab -- a popup window holding one -- so a test for
1057/// `!sender.tab` shut this listener out of it, and the answer the user clicked
1058/// never arrived: every grant settled through the window merely CLOSING, which is
1059/// why a decline and an unseen dismissal were once the same event. A content
1060/// script on a web page can never match this prefix, so nothing is loosened.
1061const OURS = chrome.runtime.getURL('');
1062chrome.runtime.onMessage.addListener((msg, sender, respond) => {
1063 if (!msg || !sender.url || !sender.url.startsWith(OURS)) return false;
1064
1065 (async () => {
1066 try {
1067 if (msg.type === 'grant') {
1068 settleGrant(msg.nonce, msg.answer);
1069 return respond({ ok: true });
1070 }
1071 if (msg.type === 'panel') {
1072 const s = await sync();
1073 await I.ready();
1074 return respond({
1075 ok: true,
1076 version: VERSION,
1077 mode: s.mode,
1078 url: s.url,
1079 title: s.title,
1080 reason: reasonText(s),
1081 granted: await grants(),
1082 pending: pendingQuestion(),
1083 });
1084 }
1085 if (msg.type === 'raise') {
1086 return respond(await raisePending());
1087 }
1088 if (msg.type === 'revoke') {
1089 // The machine hand is our grant, not Chrome's, so it comes back
1090 // a different way -- but it comes back from the same button, in
1091 // the same list, which is the only part the user should notice.
1092 // It comes back for ONE origin, because that is how it was given.
1093 if (HAND.ours(msg.pattern)) await HAND.revoke(HAND.originOfPattern(msg.pattern));
1094 else await chrome.permissions.remove({ origins: [msg.pattern] });
1095 return respond({ ok: true, granted: await grants() });
1096 }
1097 if (msg.type === 'takeover') {
1098 return respond(await doTakeover());
1099 }
1100 respond({ ok: false, error: 'unknown' });
1101 } catch (e) {
1102 respond({ ok: false, error: String((e && e.message) || e) });
1103 }
1104 })();
1105 return true;
1106});