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. |
| 29 | importScripts('i18n.js', 'hand.js'); |
| 30 | |
| 31 | const VERSION = '0.1.0'; |
| 32 | |
| 33 | const HAND = globalThis.DaimondHand; |
| 34 | |
| 35 | const I = globalThis.DaimondExtI18n; |
| 36 | const T = (...a) => I.t(...a); |
| 37 | |
| 38 | // --------------------------------------------------------------------------- |
| 39 | // Constants |
| 40 | // --------------------------------------------------------------------------- |
| 41 | |
| 42 | /// The canonical refusals from the Web panel contract, verbatim. |
| 43 | const 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.'; |
| 44 | const 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. |
| 47 | const 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. |
| 51 | const 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. |
| 55 | const 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. |
| 67 | const 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. |
| 77 | const 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 | |
| 87 | const 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. |
| 104 | const 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. |
| 113 | function 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. |
| 123 | function 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. |
| 132 | const ALL_URLS = '<all_urls>'; |
| 133 | |
| 134 | /// Reads the whole state. Cheap, and always correct after an eviction. |
| 135 | async 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. |
| 141 | async 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. |
| 159 | function 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. |
| 169 | function 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`. |
| 178 | function 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. |
| 190 | async 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? |
| 201 | async 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. |
| 213 | const 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. |
| 218 | const ALLOWED = 'allowed'; |
| 219 | const DECLINED = 'declined'; |
| 220 | const 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. |
| 226 | async 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. |
| 244 | function 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. |
| 252 | async 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. |
| 271 | async 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? |
| 319 | async 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. |
| 328 | HAND.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. |
| 336 | let mirrorAsk = null; |
| 337 | async 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. |
| 345 | function 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. |
| 358 | markPending(); |
| 359 | |
| 360 | /// If the user grants something, they have plainly changed their mind about the |
| 361 | /// mirror, so let it be asked for again. |
| 362 | chrome.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. |
| 369 | chrome.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? |
| 384 | async 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. |
| 395 | async 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 | |
| 414 | function 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. |
| 424 | async 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? |
| 449 | function 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. |
| 468 | async 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. |
| 494 | async 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. |
| 506 | async 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. |
| 531 | function 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. |
| 562 | async 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. |
| 598 | async 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. |
| 610 | async 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 | } |
| 624 | async 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. |
| 636 | async 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. |
| 654 | chrome.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. |
| 688 | chrome.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 | |
| 699 | chrome.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. |
| 710 | async 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 | |
| 728 | function 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. |
| 741 | async 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 | |
| 748 | const 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. |
| 1028 | chrome.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. |
| 1061 | const OURS = chrome.runtime.getURL(''); |
| 1062 | chrome.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 | }); |