56.1 KiB, 1 run
created by r2519314175:1477, 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 | /* web.js — the Web panel's driver. |
| 2 | * |
| 3 | * `window.DaimondWeb` is the ONE interface the wasm tools call. It hides which |
| 4 | * driver is attached, so the model's tools do not change when the extension |
| 5 | * appears. |
| 6 | * |
| 7 | * There are three drivers, and the difference between them is not a detail — |
| 8 | * it is the whole security model: |
| 9 | * |
| 10 | * 'none' nothing open. |
| 11 | * |
| 12 | * 'frame' the page is in an iframe. It can be SHOWN, never operated. A |
| 13 | * cross-origin document is opaque to us: we cannot read its DOM, |
| 14 | * cannot dispatch a click into it, cannot photograph it. That is the |
| 15 | * same-origin policy, and it is not a gap to be engineered around — |
| 16 | * it is the boundary the whole web rests on. The exception is a |
| 17 | * SAME-ORIGIN page (one we serve ourselves, or a file the agent has |
| 18 | * just written into the workspace), which is fully operable, and is |
| 19 | * how the agent tests a page it has built. |
| 20 | * |
| 21 | * 'ext' Daimond Hands is installed. The page is a real tab with the user's |
| 22 | * real session, and the panel mirrors it. Everything works. |
| 23 | * |
| 24 | * The point that makes the whole design cohere: a page CANNOT both hide the |
| 25 | * user's password from the agent and let the agent drive. Those are the same |
| 26 | * capability seen from two sides. The isolation that protects the credential is |
| 27 | * the isolation that blocks the automation. So the privilege has to leave the |
| 28 | * page — and once it has, credential-safety must be re-established by a |
| 29 | * mechanism we build and the user can audit. That mechanism is the handoff: |
| 30 | * while the user is signing in, the extension sends NOTHING. Not redacted — |
| 31 | * not sent. |
| 32 | */ |
| 33 | (function () { |
| 34 | 'use strict'; |
| 35 | |
| 36 | /// What the app says. Model-facing strings -- the notes a tool call returns, |
| 37 | /// which the daimon reads and acts on -- are NOT here: they stay in the |
| 38 | /// language the system prompt is written in. These are the ones a person |
| 39 | /// reads in the panel. |
| 40 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 41 | |
| 42 | var state = { |
| 43 | driver: 'none', // 'none' | 'frame' | 'local' | 'ext' |
| 44 | url: '', |
| 45 | title: '', |
| 46 | mode: 'idle', // 'idle' | 'user' | 'agent' |
| 47 | reason: '', // why the current page is view-only, if it is |
| 48 | extId: '', |
| 49 | }; |
| 50 | |
| 51 | var els = {}; |
| 52 | var deps = {}; // { onOpen, onClose, note } — supplied by daimond.js |
| 53 | var mirrorTimer = null; |
| 54 | var mirrorTick = null; // the running tick, so a button can ask for a picture at once |
| 55 | var mirrorOn = false; // the user has asked for the live picture |
| 56 | var mirrorWhy = ''; // '' a picture is arriving | 'wait' | 'silent' | 'refused' |
| 57 | |
| 58 | // ── The extension bridge ──────────────────────────────────────── |
| 59 | |
| 60 | /// The wait ran out. This is NOT a refusal — the question may still be on |
| 61 | /// screen — so it is marked, and everything that reads it treats "nobody has |
| 62 | /// answered yet" differently from "they said no". |
| 63 | function slowOpen() { |
| 64 | var e = new Error('Daimond is asking the user to approve this site. It cannot be opened ' |
| 65 | + 'until they do. Tell them what you want to do there, and try web_open ' |
| 66 | + 'again once they have said yes — or read the page with web_fetch instead.'); |
| 67 | e.timeout = true; |
| 68 | return e; |
| 69 | } |
| 70 | |
| 71 | /// Ask the extension something. Resolves its reply, or rejects with a plain |
| 72 | /// sentence the model can act on. |
| 73 | /// |
| 74 | /// `limit` overrides the default bound, which is what an `open` uses: its own |
| 75 | /// wait is the whole late window (see below), because the reply is worth |
| 76 | /// having long after the caller has stopped waiting for it. |
| 77 | function ext(cmd, extra, limit) { |
| 78 | return new Promise(function (resolve, reject) { |
| 79 | if (!state.extId || !window.chrome || !chrome.runtime || !chrome.runtime.sendMessage) { |
| 80 | reject(new Error(NO_DRIVER)); |
| 81 | return; |
| 82 | } |
| 83 | // Opening a site the user has not approved puts a question to a HUMAN, |
| 84 | // and a human may be making a cup of tea. Every call is bounded, because |
| 85 | // a tool call that never returns is a model that never speaks again — it |
| 86 | // simply hangs, with the user watching a spinner and no idea why. |
| 87 | var done = false; |
| 88 | if (!limit) limit = (cmd === 'open') ? OPEN_WAIT : 12000; |
| 89 | var timer = setTimeout(function () { |
| 90 | if (done) return; |
| 91 | done = true; |
| 92 | if (cmd === 'open') { reject(slowOpen()); return; } |
| 93 | var e = new Error('Daimond Hands did not answer in time. The page may be busy; try again.'); |
| 94 | e.timeout = true; |
| 95 | reject(e); |
| 96 | }, limit); |
| 97 | function settle(fn, v) { |
| 98 | if (done) return; |
| 99 | done = true; |
| 100 | clearTimeout(timer); |
| 101 | fn(v); |
| 102 | } |
| 103 | var msg = Object.assign({ cmd: cmd }, extra || {}); |
| 104 | try { |
| 105 | chrome.runtime.sendMessage(state.extId, msg, function (reply) { |
| 106 | // A missing extension surfaces here, not as a throw. |
| 107 | if (chrome.runtime.lastError || !reply) { settle(reject, new Error(NO_DRIVER)); return; } |
| 108 | if (reply.ok === false) { |
| 109 | var e = new Error(reply.error || 'Daimond Hands refused that.'); |
| 110 | e.confirm = !!reply.confirm; |
| 111 | settle(reject, e); |
| 112 | return; |
| 113 | } |
| 114 | settle(resolve, reply); |
| 115 | }); |
| 116 | } catch (e) { settle(reject, new Error(NO_DRIVER)); } |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | var NO_DRIVER = 'No driver is attached, so this page can be shown but not ' |
| 121 | + 'operated. Ask the user to install Daimond Hands, or use web_fetch to read it instead.'; |
| 122 | var NO_PAGE = 'No page is open. Call web_open first.'; |
| 123 | var NOT_YOURS = 'You are not driving. The user is entering something private, ' |
| 124 | + 'and Daimond is not watching. Wait for them to hand back the wheel.'; |
| 125 | |
| 126 | // ── A late answer ─────────────────────────────────────────────── |
| 127 | // |
| 128 | // The wait above is bounded so the model is never left hanging. The |
| 129 | // EXTENSION, though, does not give up when we do: the grant window stays on |
| 130 | // screen, the toolbar keeps the question, and when the user finally clicks |
| 131 | // Allow the tab really does open and really is driven. That reply used to be |
| 132 | // dropped on the floor — so a user who answered slowly watched Daimond drive |
| 133 | // a real tab while the panel said the site was never approved and the model |
| 134 | // was told to try again. Two things follow. |
| 135 | // |
| 136 | // First, the REQUEST outlives our wait: it is bounded by the whole late |
| 137 | // window, and whatever it finally answers is reconciled with what the panel |
| 138 | // is showing — the page is adopted, or the stale note is replaced by the real |
| 139 | // refusal. Then, after the late window, we genuinely give up: the record is |
| 140 | // dropped and a further reply changes nothing. |
| 141 | // |
| 142 | // Second, a retry while the question is still on screen ATTACHES to the |
| 143 | // answer already coming rather than sending a second `open`. The extension |
| 144 | // asks per request, so two requests would be two grant windows stacked on the |
| 145 | // user — and the attach is what lets a retried web_open pick up the slow |
| 146 | // answer as its own proper tool result. |
| 147 | |
| 148 | var OPEN_WAIT = 45000; // what a caller waits for a human to answer |
| 149 | var LATE_WAIT = 240000; // how much longer the request is kept alive, then dropped |
| 150 | |
| 151 | /// The one `open` the extension has not answered yet. |
| 152 | var liveOpen = null; // { url, seq, settled, waiters } |
| 153 | |
| 154 | /// What the panel was last ASKED to show. A late answer may touch the panel |
| 155 | /// only while this still names its page: anything newer — another site, the |
| 156 | /// guide, a close — owns the panel now, and repainting over it would be the |
| 157 | /// panel lying about what is on screen. |
| 158 | var claim = { seq: 0, url: '' }; |
| 159 | |
| 160 | /// The failed-open note now on screen, and which request left it there. |
| 161 | var stale = null; // { seq, url } |
| 162 | |
| 163 | /// A line to put above the driving note, when the panel had already given up. |
| 164 | var lateLead = ''; |
| 165 | |
| 166 | /// Claim the panel for a page. Returns the sequence number the caller must |
| 167 | /// carry, so anything that answers later can tell whether it is still wanted. |
| 168 | function claimPage(url) { |
| 169 | stale = null; |
| 170 | lateLead = ''; |
| 171 | claim = { seq: claim.seq + 1, url: url || '' }; |
| 172 | return claim.seq; |
| 173 | } |
| 174 | |
| 175 | /// Has the panel moved on since this page was asked for? |
| 176 | function superseded(seq, url) { |
| 177 | return claim.seq !== seq && !sameTarget(claim.url, url); |
| 178 | } |
| 179 | |
| 180 | /// Whether two addresses name the same page. Deliberately literal: this is |
| 181 | /// about the request the panel made, not about where the site redirected to. |
| 182 | function sameTarget(a, b) { |
| 183 | if (!a || !b) return false; |
| 184 | return norm(a) === norm(b); |
| 185 | function norm(u) { |
| 186 | try { |
| 187 | var x = new URL(u, location.href); |
| 188 | return x.origin + (x.pathname === '/' ? '' : x.pathname) + x.search; |
| 189 | } catch (e) { return String(u); } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Ask the extension to open a page, and keep the request alive past our own |
| 194 | /// wait. Rejects with a marked timeout error when the human has not answered |
| 195 | /// in time; the request itself carries on. |
| 196 | function openViaExt(url, seq) { |
| 197 | var rec = (liveOpen && !liveOpen.settled && sameTarget(liveOpen.url, url)) |
| 198 | ? liveOpen : startLive(url); |
| 199 | rec.seq = seq; // the newest asker owns the outcome |
| 200 | return new Promise(function (resolve, reject) { |
| 201 | var w = { done: false, timer: null }; |
| 202 | w.settle = function (ok, v) { |
| 203 | if (w.done) return; |
| 204 | w.done = true; |
| 205 | clearTimeout(w.timer); |
| 206 | if (ok) resolve(v); else reject(v); |
| 207 | }; |
| 208 | w.timer = setTimeout(function () { |
| 209 | if (w.done) return; |
| 210 | w.done = true; |
| 211 | reject(slowOpen()); |
| 212 | }, OPEN_WAIT); |
| 213 | rec.waiters.push(w); |
| 214 | }); |
| 215 | } |
| 216 | |
| 217 | function startLive(url) { |
| 218 | var rec = { url: url, seq: claim.seq, settled: false, waiters: [] }; |
| 219 | liveOpen = rec; |
| 220 | ext('open', { url: url }, OPEN_WAIT + LATE_WAIT).then( |
| 221 | function (r) { settleLive(rec, true, r); }, |
| 222 | function (e) { settleLive(rec, false, e); }); |
| 223 | return rec; |
| 224 | } |
| 225 | |
| 226 | /// The extension answered — or the late window closed. Anyone still waiting |
| 227 | /// gets it as their own tool result; if nobody is, the panel is reconciled. |
| 228 | function settleLive(rec, ok, v) { |
| 229 | if (rec.settled) return; |
| 230 | rec.settled = true; |
| 231 | if (liveOpen === rec) liveOpen = null; |
| 232 | var heard = false; |
| 233 | for (var i = 0; i < rec.waiters.length; i++) { |
| 234 | if (!rec.waiters[i].done) heard = true; |
| 235 | rec.waiters[i].settle(ok, v); |
| 236 | } |
| 237 | rec.waiters = []; |
| 238 | if (!heard) lateAnswer(rec, ok, v); |
| 239 | } |
| 240 | |
| 241 | /// The answer came after everyone had stopped waiting. |
| 242 | function lateAnswer(rec, ok, v) { |
| 243 | if (superseded(rec.seq, rec.url)) return; |
| 244 | // Only the note this very request left behind may be replaced. If it is |
| 245 | // gone, the panel is showing something else and this is no longer its |
| 246 | // business. |
| 247 | if (!stale || stale.seq !== rec.seq) return; |
| 248 | if (!ok) { |
| 249 | // Our own giving up is not an answer; only a real refusal is. |
| 250 | if (v && v.timeout) return; |
| 251 | notApproved(rec.url, (v && v.message) || '', true); |
| 252 | render(); |
| 253 | return; |
| 254 | } |
| 255 | showPanel(); |
| 256 | adoptPage(v, rec.url, t('web.approved_late', { host: esc(hostOf(rec.url)) })); |
| 257 | } |
| 258 | |
| 259 | /// Take the page the extension has opened as the panel's own. |
| 260 | function adoptPage(r, url, lead) { |
| 261 | stale = null; |
| 262 | lateLead = lead || ''; |
| 263 | state.driver = 'ext'; |
| 264 | state.url = r.url || url; |
| 265 | state.title = r.title || ''; |
| 266 | state.mode = r.mode || 'agent'; |
| 267 | state.reason = ''; |
| 268 | hideText(); |
| 269 | note(''); |
| 270 | startMirror(); |
| 271 | render(); |
| 272 | } |
| 273 | |
| 274 | /// The panel's word on an open that did not happen. `late` marks an answer |
| 275 | /// that arrived after Daimond had stopped waiting, which is the difference |
| 276 | /// between "nobody answered" and "they answered, slowly". |
| 277 | function notApproved(url, msg, late) { |
| 278 | note(t('web.not_approved', { host: esc(hostOf(url)) }) |
| 279 | + (late ? t('web.answer_late') : '') |
| 280 | + (/not approved|declined/i.test(msg) ? t('web.approval_closed') : esc(msg))); |
| 281 | } |
| 282 | |
| 283 | /// Look for the hands. They are optional, and their absence is a normal state, |
| 284 | /// not an error. |
| 285 | /// |
| 286 | /// The extension stamps its own id on <html> — the page hard-codes nothing, so |
| 287 | /// a rebuilt extension with a different id still finds its way home, and the |
| 288 | /// ABSENCE of the stamp is exactly how we know there is no driver. |
| 289 | async function detect() { |
| 290 | async function adopt(id) { |
| 291 | if (!id || id === state.extId) return; |
| 292 | state.extId = id; |
| 293 | try { await ext('ping'); } catch (e) { state.extId = ''; } |
| 294 | render(); |
| 295 | } |
| 296 | // It may have stamped before we ran, or it may be about to. |
| 297 | window.addEventListener('daimond-hands', function (e) { |
| 298 | adopt(e.detail && e.detail.id); |
| 299 | }); |
| 300 | await adopt(document.documentElement.dataset.daimondHands || ''); |
| 301 | } |
| 302 | function hasExt() { return !!state.extId; } |
| 303 | |
| 304 | // ── Same-origin detection ─────────────────────────────────────── |
| 305 | |
| 306 | /// Can we reach into this frame? Only if it is ours. Everything else is |
| 307 | /// opaque, and pretending otherwise would be the one lie in the app. |
| 308 | function sameOrigin(url) { |
| 309 | try { |
| 310 | var u = new URL(url, location.href); |
| 311 | return u.origin === location.origin || u.protocol === 'blob:'; |
| 312 | } catch (e) { return false; } |
| 313 | } |
| 314 | |
| 315 | // ── The gateway ───────────────────────────────────────────────── |
| 316 | |
| 317 | async function gw(path, body) { |
| 318 | var r = await fetch(path, { |
| 319 | method: 'POST', |
| 320 | headers: { 'content-type': 'application/json' }, |
| 321 | credentials: 'same-origin', |
| 322 | body: JSON.stringify(body), |
| 323 | }); |
| 324 | var j = null; |
| 325 | try { j = await r.json(); } catch (e) { /* not JSON */ } |
| 326 | if (!r.ok) { |
| 327 | throw new Error((j && j.error) || 'The web service could not reach that page.'); |
| 328 | } |
| 329 | // Fetching a page costs credits, and the reply says what is left. One place owns that |
| 330 | // number; this hands it over rather than letting the header go stale. |
| 331 | if (window.DaimondGateway && DaimondGateway.noteBalance) DaimondGateway.noteBalance(j); |
| 332 | return j; |
| 333 | } |
| 334 | |
| 335 | // ── The interface ─────────────────────────────────────────────── |
| 336 | |
| 337 | function status() { |
| 338 | // `reason` says WHY a page is view-only (cross-origin, no driver, a |
| 339 | // private address), so a caller need not guess from an empty `mode`. |
| 340 | return { driver: state.driver, url: state.url, title: state.title, |
| 341 | mode: state.mode, reason: state.reason || '' }; |
| 342 | } |
| 343 | |
| 344 | /// Bring the panel forward. Guarded, because the driver is loaded on pages |
| 345 | /// that have no layout engine (the harness), and an open that throws before |
| 346 | /// it has started is worse than one that cannot reveal itself. |
| 347 | function showPanel() { |
| 348 | if (!window.DaimondPanels) return; |
| 349 | DaimondPanels.show('web'); |
| 350 | DaimondPanels.reflow(); |
| 351 | } |
| 352 | |
| 353 | /// Show a page. Under the extension this is a real tab with the user's real |
| 354 | /// session; otherwise it is an iframe, which many sites simply refuse. We ask |
| 355 | /// the gateway FIRST whether the site will frame, because a parent page |
| 356 | /// cannot detect frame refusal reliably — the load event fires either way. |
| 357 | async function open(url) { |
| 358 | url = String(url || '').trim(); |
| 359 | // Whatever was shown before — a read-only text copy especially — is torn |
| 360 | // down before the new page, so the panel never shows one site through |
| 361 | // the remains of another. |
| 362 | hideText(); |
| 363 | state.reason = ''; // cleared each open; set only where a page is view-only |
| 364 | // A blob: URL is how a page the agent has just WRITTEN gets rendered, and it |
| 365 | // is same-origin — so it is not merely allowed here, it is the one case |
| 366 | // where the agent can drive the page it made. Rejecting it as "not a web |
| 367 | // address" would have shut the door on the only automation that needs no |
| 368 | // extension at all. |
| 369 | var blob = /^blob:/i.test(url); |
| 370 | // A page the agent built in the workspace is opened by its path, not a |
| 371 | // URL. That is the headline local-driver case -- "drive the page you |
| 372 | // made" -- and it used to be lost: `page.html` matched the bare-domain |
| 373 | // rule and was rewritten to `https://page.html`, a dead cross-origin |
| 374 | // frame. An HTML file path is now read from the workspace and rendered in |
| 375 | // the sandboxed local driver instead. |
| 376 | if (!blob && !/^https?:\/\//i.test(url) && /\.x?html?$/i.test(url) && deps.readFile) { |
| 377 | var html; |
| 378 | try { html = await deps.readFile(url); } |
| 379 | catch (e) { throw new Error('No such page in the workspace: ' + url); } |
| 380 | showPanel(); |
| 381 | stopMirror(); |
| 382 | claimPage(url); |
| 383 | state.driver = 'local'; |
| 384 | state.url = url; |
| 385 | state.title = url.split('/').pop(); |
| 386 | state.mode = 'agent'; |
| 387 | els.frame.src = URL.createObjectURL(new Blob([wrap(html)], { type: 'text/html' })); |
| 388 | note(''); |
| 389 | render(); |
| 390 | return { |
| 391 | url: url, framed: true, driver: 'local', title: state.title, mode: 'agent', |
| 392 | note: 'This page is Daimond\'s own, built in the workspace, so it can be ' |
| 393 | + 'operated: snapshot it and click.', |
| 394 | }; |
| 395 | } |
| 396 | if (!blob && !/^https?:\/\//i.test(url)) { |
| 397 | if (/^[\w.-]+\.[a-z]{2,}(\/|$)/i.test(url)) url = 'https://' + url; |
| 398 | else throw new Error('That is not a web address. Give a full http(s) URL.'); |
| 399 | } |
| 400 | showPanel(); |
| 401 | // From here the panel is claimed for this page, and anything still |
| 402 | // speaking for the last one has been superseded. |
| 403 | var seq = claimPage(url); |
| 404 | |
| 405 | if (hasExt()) { |
| 406 | // Opening a site the user has not approved before pops up a small |
| 407 | // Daimond Hands window asking them to allow it — a separate window |
| 408 | // that is easy to miss. Say it is coming, so the panel is not just a |
| 409 | // blank wait while an approval window sits unnoticed behind it. |
| 410 | // |
| 411 | // And say what will happen AFTER they allow it, before it happens: the |
| 412 | // page arrives as a browser window of its own, and a user who was not |
| 413 | // told that reads it as Daimond having launched a browser instead of |
| 414 | // using the panel. |
| 415 | state.driver = 'ext'; |
| 416 | state.url = url; |
| 417 | // The last page's title and mode do not describe this one. Left as they |
| 418 | // were, a site opened while the user still had the wheel on the previous |
| 419 | // tab drew the blindfold over the new site's opening note — the panel |
| 420 | // saying "you're driving" about a page nobody has opened yet. |
| 421 | state.title = ''; |
| 422 | state.mode = 'idle'; |
| 423 | note(t('web.opening', { host: esc(hostOf(url)) }) + '<br>' + t('web.real_tab')); |
| 424 | render(); |
| 425 | var r; |
| 426 | try { |
| 427 | r = await openViaExt(url, seq); |
| 428 | } catch (e) { |
| 429 | // Declined, closed, or timed out: say what to do, not just the raw |
| 430 | // error. The note is stamped with this request, so a late answer can |
| 431 | // tell whether it is still the one on screen. |
| 432 | stale = { seq: seq, url: url }; |
| 433 | notApproved(url, e.message, false); |
| 434 | render(); |
| 435 | throw e; |
| 436 | } |
| 437 | // The wait is long, and the user or the model may have moved on. The |
| 438 | // model is still told its page is open — that is true, and the tab is |
| 439 | // there — but the panel now belongs to whatever was asked for since. |
| 440 | if (superseded(seq, url)) { |
| 441 | return { url: r.url || url, framed: false, driver: 'ext', |
| 442 | title: r.title || '', mode: r.mode || 'agent' }; |
| 443 | } |
| 444 | adoptPage(r, url); |
| 445 | return { url: state.url, framed: false, driver: 'ext', title: state.title, mode: state.mode }; |
| 446 | } |
| 447 | |
| 448 | // No hands. Show it if the site allows framing, and say so plainly if not. |
| 449 | // Our own blob has nothing to ask the gateway about. |
| 450 | var framable = true, why = ''; |
| 451 | try { |
| 452 | if (blob) throw new Error('skip'); |
| 453 | var h = await gw('/api/web/head', { url: url }); |
| 454 | framable = !!h.framable; |
| 455 | if (!framable) why = 'refuses to be shown inside another page'; |
| 456 | } catch (e) { |
| 457 | // The gateway is optional, so a frame is tried even without it — but |
| 458 | // never for a loopback or private-network address. Those are the |
| 459 | // gateway's own control port and the machine's neighbours, not the |
| 460 | // public web, and framing one on a failed probe once loaded them |
| 461 | // straight into the panel. |
| 462 | if (isPrivateHost(url)) { |
| 463 | framable = false; |
| 464 | why = 'is a private address Daimond will not load'; |
| 465 | } else { |
| 466 | framable = true; |
| 467 | } |
| 468 | } |
| 469 | stopMirror(); |
| 470 | var ours = sameOrigin(url); |
| 471 | state.driver = ours ? 'local' : 'frame'; |
| 472 | state.url = url; |
| 473 | state.title = ''; |
| 474 | state.mode = ours ? 'agent' : 'idle'; |
| 475 | // An operable page has no reason to give; a shown-only one records why, so |
| 476 | // status() can tell the user rather than leaving them to guess. |
| 477 | state.reason = ours ? '' |
| 478 | : 'This page is cross-origin' + (why ? ' — it ' + why : '') |
| 479 | + ', so it can be shown but not operated. Read it with web_fetch, ' |
| 480 | + 'or install Daimond Hands to drive it.'; |
| 481 | if (ours) { |
| 482 | // Our page, wrapped with the bridge and dropped into the SANDBOX. It runs |
| 483 | // in an opaque origin — it cannot touch our localStorage or our OPFS — |
| 484 | // and it answers us over postMessage. Isolated and operable at once. |
| 485 | els.frame.src = await localPage(url); |
| 486 | note(''); |
| 487 | } else if (framable) { |
| 488 | els.frame.src = url; |
| 489 | note(''); |
| 490 | } else { |
| 491 | els.frame.removeAttribute('src'); |
| 492 | blocked(url); |
| 493 | } |
| 494 | render(); |
| 495 | return { |
| 496 | url: url, framed: framable, driver: state.driver, title: '', |
| 497 | mode: state.mode, |
| 498 | // The model is told, in the tool result, exactly what it may do next. |
| 499 | note: ours |
| 500 | ? 'This page is Daimond\'s own, so it can be operated: snapshot it and click.' |
| 501 | : 'This page is cross-origin. It is SHOWN to the user, but it cannot be ' |
| 502 | + 'operated from the browser. Use web_fetch to read it, or ask the user ' |
| 503 | + 'to install Daimond Hands to drive it.', |
| 504 | }; |
| 505 | } |
| 506 | |
| 507 | async function close() { |
| 508 | stopMirror(); |
| 509 | // The live view was a choice about a page that is now gone. It survives an |
| 510 | // open — see the mirror section — but not a close, which is the user |
| 511 | // putting the whole thing away. |
| 512 | mirrorOn = false; |
| 513 | mirrorWhy = ''; |
| 514 | claimPage(''); // nothing still coming may repaint this panel |
| 515 | if (hasExt() && state.driver === 'ext') { try { await ext('close'); } catch (e) { /* gone */ } } |
| 516 | // The frame is NAVIGATED away from the closed page, not emptied. |
| 517 | // |
| 518 | // `removeAttribute('src')` DESTROYS the frame's browsing context, and |
| 519 | // `render` below then puts a new one straight back: it sees `driver === |
| 520 | // 'none'`, and its idle branch calls `guide('index.html', true)`, which |
| 521 | // assigns `els.frame.src`. Torn down and re-created in the same task, at |
| 522 | // the same URL -- Chromium reuses the frame id for the new one and |
| 523 | // Playwright's frame manager asserts on the re-attach, killing the page. |
| 524 | // Measured 2026-08-14 in world 18: `DaimondWeb.close()` twice in a row, |
| 525 | // and `dev/verify_webwatch.mjs` had been dying at case C on the close |
| 526 | // after case A ever since it was written, for the same reason. |
| 527 | // |
| 528 | // Letting `render` navigate the frame to the guide discards the closed |
| 529 | // page's document just as thoroughly, and the frame survives it. The only |
| 530 | // case that still has to blank the frame by hand is a panel that was |
| 531 | // never built, where there is no `render` to put anything back. |
| 532 | // |
| 533 | // The disowning above is NOT what changed: an open may still be in flight, |
| 534 | // and `claimPage('')` is what stops its late answer painting a panel the |
| 535 | // user has closed. |
| 536 | if (!els.url) els.frame.removeAttribute('src'); |
| 537 | state.driver = 'none'; |
| 538 | state.url = ''; |
| 539 | state.title = ''; |
| 540 | state.mode = 'idle'; |
| 541 | note(''); |
| 542 | DaimondPanels.hide('web'); |
| 543 | render(); |
| 544 | return { ok: true }; |
| 545 | } |
| 546 | |
| 547 | /// Show Daimond's own user guide — a real static site at `/guide`, shown here in the panel. |
| 548 | /// |
| 549 | /// It is our own trusted page, not something the agent drives, so it needs none of the |
| 550 | /// sandbox-bridge machinery an external site gets: it is loaded straight into the frame and |
| 551 | /// navigates between its own pages by itself. Reachable directly at `/guide` too. |
| 552 | function guide(sub, noShow) { |
| 553 | stopMirror(); |
| 554 | claimPage(''); // the guide owns the panel now |
| 555 | state.driver = 'guide'; |
| 556 | state.url = 'guide/'; |
| 557 | state.title = t('web.guide'); |
| 558 | state.mode = 'idle'; |
| 559 | note(''); |
| 560 | els.frame.style.visibility = ''; |
| 561 | // The RESTING call does not re-navigate a frame that is already there. |
| 562 | // |
| 563 | // Re-assigning the src a frame already holds is not a no-op: the document |
| 564 | // is torn down and loaded again, so the reader's place in the guide is |
| 565 | // thrown away. `render`'s idle branch makes that call on every close, and |
| 566 | // nobody asked for it. An EXPLICIT `guide()` -- the header "?" -- still |
| 567 | // re-assigns, because asking for the guide while looking at it means |
| 568 | // reload; dev/verify_webback.mjs depends on exactly that to get a fresh |
| 569 | // document into the frame after it has taken the sandbox off. |
| 570 | var want = 'guide/' + (sub || 'index.html'); |
| 571 | if (!noShow || els.frame.getAttribute('src') !== want) els.frame.src = want; |
| 572 | render(); |
| 573 | // `noShow` is set when the guide is loaded as the panel's own resting |
| 574 | // content (see render), where forcing the panel open would be wrong. The |
| 575 | // header "?" and an explicit request leave it unset, and do open the panel. |
| 576 | if (!noShow && window.DaimondPanels) DaimondPanels.show('web'); |
| 577 | return { ok: true }; |
| 578 | } |
| 579 | |
| 580 | /// Read any page, whatever it does about framing. This goes through the |
| 581 | /// gateway, so it is the one route that always works — and the one route the |
| 582 | /// user must never log in through, because a page served from our origin is a |
| 583 | /// page we can read. |
| 584 | async function fetchPage(url) { |
| 585 | var j = await gw('/api/web/fetch', { url: String(url || '').trim() }); |
| 586 | return { |
| 587 | url: j.url, title: j.title, text: j.text, bytes: j.bytes, |
| 588 | readOnly: true, |
| 589 | note: 'Read through the gateway. This is a copy of the page, not a session on it — ' |
| 590 | + 'do not try to sign in here.', |
| 591 | }; |
| 592 | } |
| 593 | |
| 594 | /// The accessibility tree, with refs to act on. Never raw HTML: page text is |
| 595 | /// the least trustworthy string in the application, and a model that acts on |
| 596 | /// a selector it invented from page text is a model a page can steer. |
| 597 | async function snapshot() { |
| 598 | if (state.driver === 'none') throw new Error(NO_PAGE); |
| 599 | if (state.driver === 'ext') { |
| 600 | if (state.mode === 'user') throw new Error(NOT_YOURS); |
| 601 | var r = await ext('snapshot'); |
| 602 | state.url = r.url || state.url; |
| 603 | state.title = r.title || state.title; |
| 604 | render(); |
| 605 | return { url: r.url, title: r.title, nodes: r.nodes, truncated: !!r.truncated }; |
| 606 | } |
| 607 | if (state.driver !== 'local') throw new Error(NO_DRIVER); |
| 608 | var r = await bridge('snapshot'); |
| 609 | state.title = r.title || ''; |
| 610 | return { url: state.url, title: r.title, nodes: r.nodes, truncated: !!r.truncated }; |
| 611 | } |
| 612 | |
| 613 | /// The rendered TEXT of the page being driven — the reliable way to READ a |
| 614 | /// page (a price, a table, an article), as opposed to snapshot, which is for |
| 615 | /// acting. Works on a real tab (ext) and on Daimond's own pages (local); a |
| 616 | /// cross-origin page merely SHOWN can only be read through web_fetch. |
| 617 | async function read() { |
| 618 | if (state.driver === 'none') throw new Error(NO_PAGE); |
| 619 | if (state.driver === 'ext') { |
| 620 | if (state.mode === 'user') throw new Error(NOT_YOURS); |
| 621 | var r = await ext('read'); |
| 622 | return { url: r.url, title: r.title, text: r.text, chars: r.chars, truncated: !!r.truncated }; |
| 623 | } |
| 624 | if (state.driver !== 'local') { |
| 625 | throw new Error('This page is only being shown, not driven, so its rendered ' |
| 626 | + 'text is out of reach. Use web_fetch to read a cross-origin page.'); |
| 627 | } |
| 628 | var b = await bridge('read'); |
| 629 | return { url: state.url, title: b.title, text: b.text, chars: b.chars, truncated: !!b.truncated }; |
| 630 | } |
| 631 | |
| 632 | /// A consequential action — a purchase, a send, a POST to a new origin — is |
| 633 | /// put to the USER, not confirmed by the model. A prompt-injected model that |
| 634 | /// could confirm its own action would have no gate at all; the whole point is |
| 635 | /// that a human, not the agent, says yes. So when the extension flags an |
| 636 | /// action, Daimond asks the user here and only re-issues it on their word. |
| 637 | async function confirmAndRetry(cmd, args, reason) { |
| 638 | var ok = false; |
| 639 | if (deps.confirm) ok = await deps.confirm(reason); |
| 640 | if (!ok) { |
| 641 | // The model is told the human declined — it must not try again. |
| 642 | throw new Error('The user was asked to confirm this and declined, so it ' |
| 643 | + 'was not done. Do not retry it; move on or ask them what to do instead.'); |
| 644 | } |
| 645 | return await ext(cmd, Object.assign({}, args, { confirmed: true })); |
| 646 | } |
| 647 | |
| 648 | async function click(ref) { |
| 649 | if (state.driver === 'none') throw new Error(NO_PAGE); |
| 650 | if (state.driver === 'ext') { |
| 651 | if (state.mode === 'user') throw new Error(NOT_YOURS); |
| 652 | var r; |
| 653 | try { |
| 654 | r = await ext('click', { ref: ref }); |
| 655 | } catch (e) { |
| 656 | if (e && e.confirm) r = await confirmAndRetry('click', { ref: ref }, e.message.replace(/^CONFIRM:\s*/, '')); |
| 657 | else throw e; |
| 658 | } |
| 659 | state.url = r.url || state.url; |
| 660 | render(); |
| 661 | return { ok: true, url: state.url }; |
| 662 | } |
| 663 | if (state.driver !== 'local') throw new Error(NO_DRIVER); |
| 664 | await bridge('click', { ref: ref }); |
| 665 | return { ok: true, url: state.url }; |
| 666 | } |
| 667 | |
| 668 | async function type(ref, text, submit) { |
| 669 | if (state.driver === 'none') throw new Error(NO_PAGE); |
| 670 | if (state.driver === 'ext') { |
| 671 | if (state.mode === 'user') throw new Error(NOT_YOURS); |
| 672 | try { |
| 673 | await ext('type', { ref: ref, text: text, submit: !!submit }); |
| 674 | } catch (e) { |
| 675 | // Typing that SUBMITS a consequential form is gated exactly as a |
| 676 | // click on that form's button — and the user, not the model, decides. |
| 677 | if (e && e.confirm) await confirmAndRetry('type', { ref: ref, text: text, submit: !!submit }, e.message.replace(/^CONFIRM:\s*/, '')); |
| 678 | else throw e; |
| 679 | } |
| 680 | return { ok: true }; |
| 681 | } |
| 682 | if (state.driver !== 'local') throw new Error(NO_DRIVER); |
| 683 | await bridge('type', { ref: ref, text: text, submit: !!submit }); |
| 684 | return { ok: true }; |
| 685 | } |
| 686 | |
| 687 | /// `amount` is in SCREENS, not pixels — that is what the model's tool |
| 688 | /// description promises it, and the two have to agree or it will scroll a |
| 689 | /// long page six hundred pixels at a time and conclude the page is stuck. |
| 690 | async function scroll(dir, amount) { |
| 691 | if (state.driver === 'none') throw new Error(NO_PAGE); |
| 692 | var screens = Math.max(0.1, Math.min(10, Number(amount) || 1)); |
| 693 | if (state.driver === 'ext') { |
| 694 | if (state.mode === 'user') throw new Error(NOT_YOURS); |
| 695 | await ext('scroll', { direction: dir, amount: screens }); |
| 696 | return { ok: true }; |
| 697 | } |
| 698 | if (state.driver !== 'local') throw new Error(NO_DRIVER); |
| 699 | await bridge('scroll', { direction: dir, amount: screens }); |
| 700 | return { ok: true }; |
| 701 | } |
| 702 | |
| 703 | // ── The local driver: isolate by sandbox, talk by postMessage ─── |
| 704 | // |
| 705 | // The agent writes a page into the workspace and Daimond renders it, so the |
| 706 | // agent can drive the page it just built and see whether it works. The naive |
| 707 | // way to do that is a same-origin iframe we reach into. It is also a hole |
| 708 | // straight through the app. |
| 709 | // |
| 710 | // A blob: URL INHERITS OUR ORIGIN. A page rendered that way, unsandboxed, |
| 711 | // runs as us: it can read localStorage — where the user's API key lives — and |
| 712 | // reach OPFS. And the page is written BY THE AGENT, which may itself have been |
| 713 | // steered by a web page it read a moment ago. That is the whole prompt- |
| 714 | // injection chain, ending in exfiltration, and it would have been our own |
| 715 | // preview feature that closed it. |
| 716 | // |
| 717 | // So the frame keeps its sandbox and never gets `allow-same-origin`: it runs |
| 718 | // in an opaque origin with no access to our storage at all. We cannot reach |
| 719 | // into it — and we do not need to. We control the HTML we put in it, so we |
| 720 | // wrap it with a small BRIDGE that walks its own DOM and clicks its own |
| 721 | // buttons, and answers us over postMessage. Isolation and automation at once, |
| 722 | // which is exactly what the cross-origin case cannot have. |
| 723 | |
| 724 | var pending = {}; // id -> {resolve, reject} |
| 725 | var msgSeq = 0; |
| 726 | |
| 727 | /// The script wrapped around a page we render, so it can be driven from |
| 728 | /// outside without being trusted from inside. |
| 729 | function bridgeSource() { |
| 730 | return '(' + function () { |
| 731 | var refs = []; |
| 732 | function vis(el) { |
| 733 | if (el.hidden) return false; |
| 734 | var r = el.getBoundingClientRect(); |
| 735 | if (!r.width && !r.height) return false; |
| 736 | var st = getComputedStyle(el); |
| 737 | return st.visibility !== 'hidden' && st.display !== 'none'; |
| 738 | } |
| 739 | function inter(el) { return /^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/.test(el.tagName); } |
| 740 | function secret(el) { |
| 741 | var ty = (el.type || '').toLowerCase(); |
| 742 | if (ty === 'password' || ty === 'hidden') return true; |
| 743 | var ac = (el.getAttribute('autocomplete') || '').toLowerCase(); |
| 744 | return /^cc-|password|one-time-code/.test(ac); |
| 745 | } |
| 746 | function role(el) { |
| 747 | var explicit = el.getAttribute('role'); |
| 748 | if (explicit) return explicit; |
| 749 | switch (el.tagName) { |
| 750 | case 'A': return 'link'; |
| 751 | case 'BUTTON': return 'button'; |
| 752 | case 'SELECT': return 'combobox'; |
| 753 | case 'TEXTAREA': return 'textbox'; |
| 754 | case 'SUMMARY': return 'summary'; |
| 755 | case 'LI': return 'listitem'; |
| 756 | case 'P': return 'text'; |
| 757 | case 'H1': case 'H2': case 'H3': case 'H4': return 'heading'; |
| 758 | case 'LABEL': return 'label'; |
| 759 | case 'INPUT': |
| 760 | var ty = (el.type || 'text').toLowerCase(); |
| 761 | if (ty === 'submit' || ty === 'button') return 'button'; |
| 762 | if (ty === 'checkbox') return 'checkbox'; |
| 763 | if (ty === 'radio') return 'radio'; |
| 764 | if (ty === 'password') return 'password'; |
| 765 | return 'textbox'; |
| 766 | } |
| 767 | return ''; |
| 768 | } |
| 769 | function name(el) { |
| 770 | var n = el.getAttribute('aria-label') |
| 771 | || (el.labels && el.labels[0] && el.labels[0].textContent) |
| 772 | || el.getAttribute('placeholder') || el.getAttribute('title') |
| 773 | || el.getAttribute('alt') |
| 774 | || (el.tagName === 'INPUT' && el.type === 'submit' ? el.value : '') |
| 775 | || el.textContent || ''; |
| 776 | return String(n).replace(/\s+/g, ' ').trim(); |
| 777 | } |
| 778 | function snapshot() { |
| 779 | refs = []; |
| 780 | var nodes = [], CAP = 200; |
| 781 | var all = document.body.querySelectorAll( |
| 782 | 'a[href],button,input,select,textarea,[role],h1,h2,h3,h4,li,p,label,summary'); |
| 783 | for (var i = 0; i < all.length && nodes.length < CAP; i++) { |
| 784 | var el = all[i]; |
| 785 | if (!vis(el)) continue; |
| 786 | var r = role(el); |
| 787 | if (!r) continue; |
| 788 | var nm = name(el); |
| 789 | if (!nm && !inter(el)) continue; |
| 790 | var ref = refs.push(el) - 1; |
| 791 | var n = { ref: ref, role: r, name: nm.slice(0, 160) }; |
| 792 | // A password is never serialised, even here, where we are the ones |
| 793 | // driving. There is no reason a model needs it. |
| 794 | if (inter(el) && 'value' in el && !secret(el)) n.value = String(el.value || '').slice(0, 160); |
| 795 | nodes.push(n); |
| 796 | } |
| 797 | return { title: document.title, nodes: nodes, truncated: all.length > CAP }; |
| 798 | } |
| 799 | addEventListener('message', function (e) { |
| 800 | var m = e.data; |
| 801 | if (!m || m.dw !== 1 || !m.cmd) return; |
| 802 | var out = { dw: 1, id: m.id, ok: true }; |
| 803 | try { |
| 804 | if (m.cmd === 'snapshot') { |
| 805 | var s = snapshot(); |
| 806 | out.title = s.title; out.nodes = s.nodes; out.truncated = s.truncated; |
| 807 | } else if (m.cmd === 'read') { |
| 808 | var main = document.querySelector('main, [role="main"], article') || document.body; |
| 809 | var txt = (main.innerText || '').replace(/\n{3,}/g, '\n\n').trim(); |
| 810 | out.title = document.title; out.chars = txt.length; |
| 811 | out.truncated = txt.length > 40000; |
| 812 | out.text = txt.slice(0, 40000); |
| 813 | } else if (m.cmd === 'click') { |
| 814 | var el = refs[m.ref]; |
| 815 | if (!el) throw new Error('There is no element with ref ' + m.ref |
| 816 | + '. Snapshot the page again — refs go stale when the page changes.'); |
| 817 | el.click(); |
| 818 | } else if (m.cmd === 'type') { |
| 819 | var f = refs[m.ref]; |
| 820 | if (!f) throw new Error('There is no element with ref ' + m.ref + '. Snapshot the page again.'); |
| 821 | f.focus(); |
| 822 | f.value = m.text; |
| 823 | f.dispatchEvent(new Event('input', { bubbles: true })); |
| 824 | f.dispatchEvent(new Event('change', { bubbles: true })); |
| 825 | if (m.submit && f.form) { |
| 826 | f.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); |
| 827 | } |
| 828 | } else if (m.cmd === 'scroll') { |
| 829 | scrollBy(0, (m.direction === 'up' ? -1 : 1) * (m.amount || 1) * innerHeight * 0.9); |
| 830 | } else { |
| 831 | throw new Error('Unknown command.'); |
| 832 | } |
| 833 | } catch (err) { |
| 834 | out.ok = false; |
| 835 | out.error = String((err && err.message) || err); |
| 836 | } |
| 837 | parent.postMessage(out, '*'); |
| 838 | }); |
| 839 | parent.postMessage({ dw: 1, ready: 1, title: document.title }, '*'); |
| 840 | } + ')()'; |
| 841 | } |
| 842 | |
| 843 | /// Ask the page in the frame to do something. It answers, or it refuses; it |
| 844 | /// never gets to reach back out. |
| 845 | function bridge(cmd, extra) { |
| 846 | return new Promise(function (resolve, reject) { |
| 847 | var win = els.frame.contentWindow; |
| 848 | if (!win) { reject(new Error(NO_PAGE)); return; } |
| 849 | var id = ++msgSeq; |
| 850 | pending[id] = { resolve: resolve, reject: reject }; |
| 851 | win.postMessage(Object.assign({ dw: 1, id: id, cmd: cmd }, extra || {}), '*'); |
| 852 | setTimeout(function () { |
| 853 | if (!pending[id]) return; |
| 854 | delete pending[id]; |
| 855 | reject(new Error('The page did not answer. It may still be loading, or it ' |
| 856 | + 'may be a page Daimond cannot operate — try web_fetch to read it instead.')); |
| 857 | }, 5000); |
| 858 | }); |
| 859 | } |
| 860 | |
| 861 | /// The frame's only way back to us. We ONLY listen when the frame is our own |
| 862 | /// bridged page: a cross-origin site we are merely displaying gets `allow- |
| 863 | /// scripts`, so it too can `postMessage` at us, and without this gate a page |
| 864 | /// we promised only to SHOW could push a forged `ready` title into the |
| 865 | /// model's context. The driver being `local` is the proof the frame is ours. |
| 866 | /// Beyond that we check the sender is the frame, and never eval or trust a |
| 867 | /// word of what it says. |
| 868 | function onBridgeMessage(e) { |
| 869 | if (state.driver !== 'local') return; |
| 870 | if (!els.frame || e.source !== els.frame.contentWindow) return; |
| 871 | var m = e.data; |
| 872 | if (!m || m.dw !== 1) return; |
| 873 | if (m.ready) { state.title = m.title || ''; render(); return; } |
| 874 | var p = pending[m.id]; |
| 875 | if (!p) return; |
| 876 | delete pending[m.id]; |
| 877 | if (m.ok === false) p.reject(new Error(m.error || 'The page refused.')); |
| 878 | else p.resolve(m); |
| 879 | } |
| 880 | |
| 881 | /// Wrap a page we are about to render with the bridge. The page stays exactly |
| 882 | /// what the agent wrote; the bridge is appended, and it is the only script we |
| 883 | /// add. |
| 884 | function wrap(html) { |
| 885 | var b = '<script>' + bridgeSource() + '<\/script>'; |
| 886 | return /<\/body>/i.test(html) ? html.replace(/<\/body>/i, b + '</body>') : html + b; |
| 887 | } |
| 888 | |
| 889 | /// A page Daimond itself is rendering — from the workspace, or from anywhere |
| 890 | /// else we control. Returns a blob: URL for the SANDBOXED frame. |
| 891 | async function localPage(url) { |
| 892 | var r = await window.fetch(url); // our own blob; no network |
| 893 | var html = await r.text(); |
| 894 | return URL.createObjectURL(new Blob([wrap(html)], { type: 'text/html' })); |
| 895 | } |
| 896 | |
| 897 | |
| 898 | // ── The mirror ────────────────────────────────────────────────── |
| 899 | // Under the extension the real page is a real tab. The panel can show a LIVE |
| 900 | // PICTURE of that tab — but photographing a tab needs Chrome's broadest |
| 901 | // permission ("all your data on all websites"), so it is strictly OPT-IN. |
| 902 | // It is NEVER asked for automatically: a user who just approved one site |
| 903 | // should not be hit with the scariest prompt in the browser a second later, |
| 904 | // and polling for a picture we may not photograph is what turned into an |
| 905 | // endless stream of permission windows. By default the tab is simply open |
| 906 | // and visible — the user watches it there — and a button pulls the live view |
| 907 | // into the panel if they want it. |
| 908 | // |
| 909 | // The answer to that button belongs to the SESSION, not to the page. |
| 910 | // `mirrorOn` used to be cleared on every open, so a user who had already |
| 911 | // pulled the picture in was put back to the note at the next site with no |
| 912 | // word about why — and, having watched a browser window open, had every |
| 913 | // reason to read that as the panel not working. Turning it off is what stops |
| 914 | // the polling; opening a page is not. |
| 915 | // |
| 916 | // `mirrorWhy` is the other half. A panel with no picture must say WHICH kind |
| 917 | // of no it is: nobody has answered yet, or this tab cannot be photographed |
| 918 | // at all. Blank is the one thing it may not be. |
| 919 | |
| 920 | function startMirror() { |
| 921 | stopMirror(); |
| 922 | // A new page has no picture yet, however the last one ended. |
| 923 | mirrorWhy = mirrorOn ? 'wait' : ''; |
| 924 | var inFlight = false; // one tick at a time; never overlap |
| 925 | mirrorTick = tick; |
| 926 | mirrorTimer = setInterval(tick, 1200); |
| 927 | extNote(); |
| 928 | tick(); |
| 929 | /// Ask only `status` — the tab's URL and mode, nothing off the page and no |
| 930 | /// permission needed. This is all we poll unless the live view is on. |
| 931 | async function pollStatus() { |
| 932 | try { |
| 933 | var st = await ext('status'); |
| 934 | // Keep the broker's reason for the wheel being with the user, so the |
| 935 | // blindfold can name the cause ("the sign-in page for …") rather than |
| 936 | // only ever saying it stopped. |
| 937 | if (st && typeof st.reason === 'string') state.reason = st.reason; |
| 938 | if (st && st.mode && st.mode !== state.mode) { |
| 939 | state.mode = st.mode; |
| 940 | // The wheel handed back is a panel with no picture yet, whatever was |
| 941 | // on screen before the user took it. |
| 942 | if (mirrorOn && state.mode === 'agent') mirrorWhy = 'wait'; |
| 943 | render(); |
| 944 | extNote(); |
| 945 | } |
| 946 | if (st && st.url) { state.url = st.url; state.title = st.title || state.title; render(); } |
| 947 | } catch (e) { /* no hands; nothing to poll */ } |
| 948 | } |
| 949 | async function tick() { |
| 950 | if (inFlight) return; // the previous tick is still resolving |
| 951 | inFlight = true; |
| 952 | try { |
| 953 | // Poll pictures ONLY when the user has turned the live view on and we |
| 954 | // are not blindfolded. Otherwise poll status alone — no permission, |
| 955 | // no popup. |
| 956 | if (state.mode === 'user' || !mirrorOn) { await pollStatus(); return; } |
| 957 | try { |
| 958 | var r = await ext('frame'); |
| 959 | if (r && r.png) { |
| 960 | mirrorWhy = ''; |
| 961 | els.mirror.src = r.png; |
| 962 | els.mirror.style.display = ''; |
| 963 | els.frame.style.display = 'none'; |
| 964 | note(''); |
| 965 | } |
| 966 | if (r && r.mode && r.mode !== state.mode) { state.mode = r.mode; render(); } |
| 967 | if (r && r.url) { state.url = r.url; state.title = r.title || state.title; render(); } |
| 968 | } catch (e) { |
| 969 | var msg = (e && e.message) || ''; |
| 970 | if (/not driving/i.test(msg)) { state.mode = 'user'; render(); } |
| 971 | // The mirror was declined, or Chrome will not grant it. Stop asking |
| 972 | // — the button is how it is asked for again — and say why, rather |
| 973 | // than leaving the last photograph up as though it were live. |
| 974 | else if (/mirror|photograph/i.test(msg)) { mirrorOn = false; mirrorWhy = 'refused'; extNote(); } |
| 975 | // Anything else is silence, not a refusal: the hands may be busy, |
| 976 | // or gone. Nobody having answered is not the same as this being |
| 977 | // impossible, and the panel has to tell the two apart — this branch |
| 978 | // used to swallow the error, which left the panel blank or, worse, |
| 979 | // still showing the last photograph. |
| 980 | else { mirrorWhy = 'silent'; extNote(); } |
| 981 | } |
| 982 | } finally { inFlight = false; } |
| 983 | } |
| 984 | } |
| 985 | |
| 986 | /// What the panel says while the extension is driving a real tab. |
| 987 | /// |
| 988 | /// EVERY ext state a person can see comes through here, because a browser |
| 989 | /// window appearing beside a silent panel is indistinguishable from Daimond |
| 990 | /// having simply launched a browser and stopped. The one state it does not |
| 991 | /// own is the blindfold: there the user has the wheel, and render() speaks. |
| 992 | function extNote() { |
| 993 | if (state.driver !== 'ext') return; |
| 994 | // Under the extension the iframe is never the page. It still holds |
| 995 | // whatever it last showed — the guide, usually — and the guide under a |
| 996 | // header naming a site is the panel lying about what it displays. |
| 997 | els.frame.style.display = 'none'; |
| 998 | if (state.mode === 'user') return; |
| 999 | if (mirrorOn && !mirrorWhy) return; // a live picture is on screen |
| 1000 | els.mirror.style.display = 'none'; |
| 1001 | els.note.className = 'web-note on'; |
| 1002 | els.note.innerHTML = ''; |
| 1003 | function line(html) { |
| 1004 | var d = document.createElement('div'); |
| 1005 | d.innerHTML = html; // built here, from our own strings |
| 1006 | els.note.appendChild(d); |
| 1007 | } |
| 1008 | // When the panel had already given up on this page, say so above the rest: |
| 1009 | // the user was last told the site was not approved, and it is. |
| 1010 | if (lateLead) line(lateLead); |
| 1011 | line(t('web.driving_tab', { host: esc(hostOf(state.url)) })); |
| 1012 | // The relationship, in one sentence: the window that just appeared IS the |
| 1013 | // page, and this panel is its mirror. |
| 1014 | line(t('web.real_tab')); |
| 1015 | if (mirrorWhy === 'wait') line(t('web.mirror_wait')); |
| 1016 | else if (mirrorWhy === 'silent') line(t('web.mirror_silent')); |
| 1017 | else if (mirrorWhy === 'refused') line(t('web.mirror_refused')); |
| 1018 | if (mirrorOn) return; // already asked for; the button would say nothing |
| 1019 | var btn = document.createElement('button'); |
| 1020 | btn.textContent = t('web.show_live'); |
| 1021 | btn.addEventListener('click', function () { |
| 1022 | // The panel says it is waiting BEFORE the ask, and asks at once rather |
| 1023 | // than at the next tick: the next thing the user sees may be Chrome's |
| 1024 | // permission window, and an empty panel behind it is what made this |
| 1025 | // look broken. |
| 1026 | mirrorOn = true; |
| 1027 | mirrorWhy = 'wait'; |
| 1028 | extNote(); |
| 1029 | if (mirrorTick) mirrorTick(); |
| 1030 | }); |
| 1031 | els.note.appendChild(btn); |
| 1032 | } |
| 1033 | function stopMirror() { |
| 1034 | if (mirrorTimer) { clearInterval(mirrorTimer); mirrorTimer = null; } |
| 1035 | mirrorTick = null; |
| 1036 | els.mirror.style.display = 'none'; |
| 1037 | els.frame.style.display = ''; |
| 1038 | } |
| 1039 | |
| 1040 | /// The panel's own control cannot take the wheel — only a trusted gesture in |
| 1041 | /// the tab (the Resume overlay) or the extension popup can, and this page is |
| 1042 | /// neither. So the button just re-checks whether the user has already resumed |
| 1043 | /// there, in case the 1.2s poll has not yet caught up. |
| 1044 | async function takeover() { |
| 1045 | try { |
| 1046 | var st = await ext('status'); |
| 1047 | state.mode = (st && st.mode) || state.mode; |
| 1048 | render(); |
| 1049 | } catch (e) { /* no hands; nothing to check */ } |
| 1050 | } |
| 1051 | |
| 1052 | // ── The panel ─────────────────────────────────────────────────── |
| 1053 | |
| 1054 | /// Can the Back control actually do anything? It is drawn only where it can. |
| 1055 | /// |
| 1056 | /// A control that does nothing when it is pressed teaches the reader to |
| 1057 | /// distrust every control on the panel, so this decides whether `#web-back` is |
| 1058 | /// on screen at all. There are three separate reasons it cannot work, and all |
| 1059 | /// three hold in the app as it ships: |
| 1060 | /// |
| 1061 | /// * UNDER HANDS THE FRAME IS NOT THE PAGE. `extNote` hides the frame and |
| 1062 | /// leaves whatever it last held -- the guide, usually -- inside it. A Back |
| 1063 | /// that "worked" there would walk the GUIDE's history behind a header |
| 1064 | /// naming a live site. |
| 1065 | /// * A CROSS-ORIGIN PAGE'S HISTORY IS NOT OURS TO WALK. That is the |
| 1066 | /// same-origin policy: reading `contentWindow.history` throws. |
| 1067 | /// * OUR OWN PAGES ARE SANDBOXED WITHOUT `allow-same-origin`. See the |
| 1068 | /// `sandbox` attribute on `#web-frame` in index.html, and the note at the |
| 1069 | /// top of `guide/frame.js` explaining why it is deliberate: a page an agent |
| 1070 | /// wrote must not be able to reach the user's keys. An opaque origin is |
| 1071 | /// cross-origin to us too, so the guide and the local driver are no more |
| 1072 | /// reachable than anybody else's site. |
| 1073 | /// |
| 1074 | /// PROBED rather than decided from a table of drivers. The last reason is the |
| 1075 | /// one that could plausibly change -- a guide frame of its own, or a |
| 1076 | /// back-channel through `guide/frame.js` -- and asking the frame means the |
| 1077 | /// control returns by itself when it can work again, instead of waiting for |
| 1078 | /// somebody to remember this line. |
| 1079 | function canGoBack() { |
| 1080 | // Not a question about the frame: under the extension it is not the page, |
| 1081 | // and an idle panel has nothing in it. |
| 1082 | if (state.driver === 'ext' || state.driver === 'none') return false; |
| 1083 | try { |
| 1084 | return !!(els.frame && els.frame.contentWindow && els.frame.contentWindow.history); |
| 1085 | } catch (e) { |
| 1086 | return false; // opaque or cross-origin: not ours |
| 1087 | } |
| 1088 | } |
| 1089 | |
| 1090 | /// Draw the Back control, or take it away. |
| 1091 | /// |
| 1092 | /// Called from `render` and from the frame's own `load`: what the frame HOLDS |
| 1093 | /// is what decides, and that changes when a document arrives rather than when |
| 1094 | /// one is asked for. Painting only on render left the control judging the |
| 1095 | /// document it was about to replace. |
| 1096 | function paintBack() { |
| 1097 | if (els.back) els.back.style.display = canGoBack() ? '' : 'none'; |
| 1098 | } |
| 1099 | |
| 1100 | function render() { |
| 1101 | if (!els.url) return; |
| 1102 | // An empty iframe is a blank white rectangle, which reads as a broken panel |
| 1103 | // rather than an idle one. Say what the panel is for instead. |
| 1104 | var idle = (state.driver === 'none'); |
| 1105 | if (idle) { |
| 1106 | // The panel opens straight onto the guide, not a prompt to open it. |
| 1107 | // guide() flips the driver off 'none', so the follow-up render is not |
| 1108 | // idle and this does not recurse; noShow keeps a background render from |
| 1109 | // forcing the panel open. |
| 1110 | guide('index.html', true); |
| 1111 | return; |
| 1112 | } |
| 1113 | els.frame.style.visibility = ''; |
| 1114 | // The header names what is on screen. Our own guide says "Guide"; an external page shows |
| 1115 | // its host and path; an idle panel says what the panel is FOR, rather than "No page", |
| 1116 | // which read as broken. |
| 1117 | els.url.textContent = state.driver === 'guide' ? t('web.guide') |
| 1118 | : state.url ? hostOf(state.url) + pathOf(state.url) |
| 1119 | : t('web.panel_for'); |
| 1120 | els.url.title = state.driver === 'guide' ? t('web.guide_title') : (state.url || ''); |
| 1121 | var m = els.mode; |
| 1122 | m.className = 'web-mode' + (state.mode === 'user' ? ' user' : state.mode === 'agent' ? ' agent' : ''); |
| 1123 | m.textContent = state.mode === 'user' ? t('web.who_you') |
| 1124 | : state.mode === 'agent' ? t('web.who_daimond') |
| 1125 | : (hasExt() ? t('web.who_ready') : t('web.who_view_only')); |
| 1126 | m.title = state.mode === 'user' |
| 1127 | ? t('web.who_you_help') |
| 1128 | : state.mode === 'agent' |
| 1129 | ? t('web.who_daimond_help') |
| 1130 | : hasExt() ? t('web.who_ready_help') : t('web.who_view_only_help'); |
| 1131 | // WHY THERE ARE TWO OF THEM, said for as long as there are two of them. |
| 1132 | // Only under the extension: with a frame there is no second window to |
| 1133 | // explain, and a panel that claimed one would be lying. |
| 1134 | if (els.live) { |
| 1135 | els.live.hidden = state.driver !== 'ext'; |
| 1136 | if (!els.live.hidden) els.live.textContent = t('web.real_tab'); |
| 1137 | } |
| 1138 | paintBack(); // shown only where it can act |
| 1139 | els.blind.style.display = (state.mode === 'user') ? 'flex' : 'none'; |
| 1140 | // Name why the wheel is with the user, when the broker told us. A specific |
| 1141 | // cause ("stopped at the sign-in page for …") reassures far more than the |
| 1142 | // generic "I'm not watching", so the title carries it whenever it is known. |
| 1143 | if (state.mode === 'user') { |
| 1144 | var bt = els.blind.querySelector('.web-blind-title'); |
| 1145 | if (bt) bt.textContent = state.reason |
| 1146 | ? t('web.blind_title_at', { where: state.reason }) |
| 1147 | : t('web.blind_title'); |
| 1148 | } |
| 1149 | } |
| 1150 | |
| 1151 | function note(html) { |
| 1152 | els.note.innerHTML = ''; |
| 1153 | if (!html) { els.note.className = 'web-note'; return; } |
| 1154 | els.note.className = 'web-note on'; |
| 1155 | var p = document.createElement('div'); |
| 1156 | p.innerHTML = html; // built here, from our own strings |
| 1157 | els.note.appendChild(p); |
| 1158 | } |
| 1159 | |
| 1160 | /// A site that will not be embedded — which is MOST of them. This is not an |
| 1161 | /// error; it is the web's own clickjacking defence (X-Frame-Options / CSP), |
| 1162 | /// and it is the same in every browser. Say so plainly, and offer the three |
| 1163 | /// things that actually work: open it in a real tab, read its text, or — with |
| 1164 | /// Daimond Hands — drive it live. |
| 1165 | function blocked(url) { |
| 1166 | els.note.innerHTML = ''; |
| 1167 | els.note.className = 'web-note on'; |
| 1168 | var msg = document.createElement('div'); |
| 1169 | msg.innerHTML = t('web.blocked', { host: esc(hostOf(url)) }) + ' ' |
| 1170 | + (hasExt() ? t('web.blocked_hands') : t('web.blocked_install')); |
| 1171 | els.note.appendChild(msg); |
| 1172 | |
| 1173 | var row = document.createElement('div'); |
| 1174 | row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:center'; |
| 1175 | |
| 1176 | var tab = document.createElement('button'); |
| 1177 | tab.textContent = t('web.open_new_tab'); // free, no gateway, no extension |
| 1178 | tab.addEventListener('click', function () { window.open(url, '_blank', 'noopener'); }); |
| 1179 | row.appendChild(tab); |
| 1180 | |
| 1181 | var read = document.createElement('button'); |
| 1182 | read.textContent = t('web.read_as_text'); |
| 1183 | read.addEventListener('click', async function () { |
| 1184 | read.disabled = true; |
| 1185 | read.textContent = t('web.reading'); |
| 1186 | try { |
| 1187 | var j = await fetchPage(url); |
| 1188 | note(''); |
| 1189 | els.frame.removeAttribute('src'); |
| 1190 | showText(j.title, j.text); |
| 1191 | } catch (e) { |
| 1192 | read.disabled = false; |
| 1193 | read.textContent = t('web.read_as_text'); |
| 1194 | msg.innerHTML = t('web.read_failed', { reason: esc(e.message) }); |
| 1195 | } |
| 1196 | }); |
| 1197 | row.appendChild(read); |
| 1198 | els.note.appendChild(row); |
| 1199 | } |
| 1200 | |
| 1201 | /// Tear down the read-only text copy, if one is showing. A new page must |
| 1202 | /// never be seen through the text of the last one: the copy overlays the |
| 1203 | /// frame, so leaving it up made the panel show one site's words under |
| 1204 | /// another site's header and URL — the panel lying about what it displays. |
| 1205 | function hideText() { |
| 1206 | var pre = document.getElementById('web-text'); |
| 1207 | if (pre) { pre.style.display = 'none'; pre.innerHTML = ''; } |
| 1208 | els.frame.style.display = ''; |
| 1209 | } |
| 1210 | |
| 1211 | /// A page the gateway read for us, rendered as text. It is a copy, not a |
| 1212 | /// session — the panel says so, because a user who mistakes it for the real |
| 1213 | /// site might try to sign in to it. |
| 1214 | function showText(title, text) { |
| 1215 | els.mirror.style.display = 'none'; |
| 1216 | els.frame.style.display = 'none'; |
| 1217 | var pre = document.getElementById('web-text'); |
| 1218 | if (!pre) { |
| 1219 | pre = document.createElement('div'); |
| 1220 | pre.id = 'web-text'; |
| 1221 | pre.className = 'web-text'; |
| 1222 | els.body.appendChild(pre); |
| 1223 | } |
| 1224 | pre.style.display = ''; |
| 1225 | pre.innerHTML = ''; |
| 1226 | var badge = document.createElement('div'); |
| 1227 | badge.className = 'web-readonly'; |
| 1228 | badge.textContent = t('web.readonly_badge'); |
| 1229 | var b = document.createElement('div'); |
| 1230 | b.className = 'web-text-body'; |
| 1231 | b.textContent = text || ''; // text, never markup |
| 1232 | pre.appendChild(badge); |
| 1233 | pre.appendChild(b); |
| 1234 | } |
| 1235 | |
| 1236 | /// Whether a URL names a loopback or private-network host — the addresses a |
| 1237 | /// browser can reach but the public web cannot, and so the ones the panel |
| 1238 | /// must not be tricked into loading. A rough check on the literal host is |
| 1239 | /// enough here; the gateway does the authoritative resolve-and-vet. |
| 1240 | function isPrivateHost(u) { |
| 1241 | var h; |
| 1242 | try { h = new URL(u).hostname.toLowerCase(); } catch (e) { return false; } |
| 1243 | if (h === 'localhost' || h.endsWith('.localhost')) return true; |
| 1244 | if (h === '0.0.0.0' || h === '::1' || h === '[::1]') return true; |
| 1245 | if (/^127\./.test(h)) return true; |
| 1246 | if (/^10\./.test(h)) return true; |
| 1247 | if (/^192\.168\./.test(h)) return true; |
| 1248 | if (/^169\.254\./.test(h)) return true; // link-local |
| 1249 | if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // 172.16–31 |
| 1250 | return false; |
| 1251 | } |
| 1252 | |
| 1253 | function hostOf(u) { try { return new URL(u).host; } catch (e) { return u; } } |
| 1254 | function pathOf(u) { try { var p = new URL(u).pathname; return p === '/' ? '' : p; } catch (e) { return ''; } } |
| 1255 | function esc(s) { |
| 1256 | return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| 1257 | } |
| 1258 | |
| 1259 | function init(d) { |
| 1260 | deps = d || {}; |
| 1261 | els.frame = document.getElementById('web-frame'); |
| 1262 | els.mirror = document.getElementById('web-mirror'); |
| 1263 | els.note = document.getElementById('web-note'); |
| 1264 | els.blind = document.getElementById('web-blind'); |
| 1265 | els.live = document.getElementById('web-live'); |
| 1266 | els.url = document.getElementById('web-url'); |
| 1267 | els.mode = document.getElementById('web-mode'); |
| 1268 | els.body = document.getElementById('web-body'); |
| 1269 | els.back = document.getElementById('web-back'); |
| 1270 | if (!els.frame) return; |
| 1271 | |
| 1272 | // A document arriving is the moment Back's answer can change. See paintBack. |
| 1273 | els.frame.addEventListener('load', paintBack); |
| 1274 | window.addEventListener('message', onBridgeMessage); |
| 1275 | document.getElementById('web-takeover').addEventListener('click', takeover); |
| 1276 | document.getElementById('web-reload').addEventListener('click', function () { |
| 1277 | if (state.url) open(state.url); |
| 1278 | }); |
| 1279 | document.getElementById('web-pop').addEventListener('click', function () { |
| 1280 | if (state.url) window.open(state.url, '_blank', 'noopener'); |
| 1281 | }); |
| 1282 | els.back.addEventListener('click', function () { |
| 1283 | // `canGoBack` has already decided this control is on screen, so the |
| 1284 | // catch is a guard against the frame changing under a press rather than |
| 1285 | // the ordinary case it used to swallow. |
| 1286 | if (!canGoBack()) return; |
| 1287 | try { els.frame.contentWindow.history.back(); } catch (e) { /* gone since */ } |
| 1288 | }); |
| 1289 | detect(); |
| 1290 | render(); |
| 1291 | } |
| 1292 | |
| 1293 | // The panel's header names what is on screen and who has the wheel, and it |
| 1294 | // stays on screen, so a language change has to redraw it where it stands. |
| 1295 | if (window.DaimondI18n) { |
| 1296 | DaimondI18n.onChange(function () { if (els.url) render(); }); |
| 1297 | } |
| 1298 | |
| 1299 | window.DaimondWeb = { |
| 1300 | init: init, |
| 1301 | status: status, |
| 1302 | open: open, |
| 1303 | guide: guide, |
| 1304 | close: close, |
| 1305 | fetch: fetchPage, |
| 1306 | snapshot: snapshot, |
| 1307 | read: read, |
| 1308 | click: click, |
| 1309 | type: type, |
| 1310 | scroll: scroll, |
| 1311 | hasHands: hasExt, |
| 1312 | /// Test only. The late-answer rule is minutes long by design, and a test |
| 1313 | /// cannot spend minutes proving it; shortening the two waits changes |
| 1314 | /// nothing else. Same-origin callers only, and the worst a caller can do |
| 1315 | /// with it is make its own opens give up sooner. |
| 1316 | _setWaitsForTest: function (o) { |
| 1317 | if (o && o.open > 0) OPEN_WAIT = o.open; |
| 1318 | if (o && o.late > 0) LATE_WAIT = o.late; |
| 1319 | return { open: OPEN_WAIT, late: LATE_WAIT }; |
| 1320 | }, |
| 1321 | }; |
| 1322 | })(); |