From b20fb98c54b62e8976ead07e8cf13f84b25a71ce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 13 Jun 2026 19:46:10 +0100 Subject: [PATCH] Add active tab page snapshots --- README.md | 7 ++ docs/architecture.md | 23 +++-- extension/popup.css | 2 +- extension/popup.html | 5 + extension/popup.js | 19 ++++ extension/service-worker.js | 167 +++++++++++++++++++++++++++++++- src/loki-daemon.mjs | 83 +++++++++++++--- test/extension-ingest-smoke.mjs | 52 ++++++++++ 8 files changed, 332 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 74333c5..307a260 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ The extension posts tab metadata to `http://127.0.0.1:17666/ingest/chrome-extension`. Its normal sync does not read page DOM, cookies, or page content. +Use `Snapshot active tab` in the popup when Codex needs help reading a page that +only your authenticated Chrome session can see. That action captures only the +current active tab, records visible text, headings, links, and visible form +controls, and writes `state/loki.chrome_page_snapshot.cc`. It does not read +cookies, response bodies, password values, or hidden inputs. + The popup also exposes `Debug sweep`. Chrome will warn that Loki can debug pages because the extension uses `chrome.debugger`. A sweep attaches to each debuggable tab, probes Chrome Debugger Protocol domains, records frame/runtime/ @@ -72,6 +78,7 @@ Loki writes: - `state/loki.chrome_snapshot.cc` - `state/loki.chrome_debug_probe.cc` +- `state/loki.chrome_page_snapshot.cc` - `state/loki.provider_advertisement.cc` - `state/loki.eve_surface.cc` diff --git a/docs/architecture.md b/docs/architecture.md index 0ee2ea6..bff0f40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,9 +8,11 @@ CultMesh/Eve consumers while keeping Chrome installation easy for the operator. ## Current Mechanism The Chrome extension observes tab metadata with explicit Chrome permissions and -posts reports to Loki's localhost ingest endpoint. On command, it also uses -`chrome.debugger` to attach to debuggable tabs, probe Chrome Debugger Protocol -domains, and detach. Chrome can also expose DevTools metadata on +posts reports to Loki's localhost ingest endpoint. On explicit operator command +from the popup, it can capture the active tab's visible page text, headings, +links, and visible form controls for Codex-assisted navigation. On command, it +also uses `chrome.debugger` to attach to debuggable tabs, probe Chrome Debugger +Protocol domains, and detach. Chrome can also expose DevTools metadata on `127.0.0.1:{port}` when started with `--remote-debugging-port`. Loki normalizes these sources into one snapshot and writes typed witness documents from that state. @@ -18,6 +20,7 @@ state. ```mermaid flowchart LR Extension["Chrome extension"] --> Ingest["extension ingest"] + Snapshot["active page snapshot"] --> Ingest Debugger["chrome.debugger sweep"] --> Ingest Chrome["Chrome CDP endpoint"] --> Observe["observe CDP"] Config["Loki config"] --> Ingest @@ -35,6 +38,8 @@ flowchart LR - CDP reachability is observed state, not assumed capability. - Extension reachability is observed state, not assumed capability. - Debug sweeps are explicit observations, not continuous hidden attachment. +- Page snapshots are explicit active-tab observations, not continuous page + surveillance. - Chrome owns page reality; Loki owns only the observation snapshot. - `.cc` witnesses are the durable local state surface. - Eve/CultUI output is derived from the same snapshot as the witness. @@ -45,12 +50,12 @@ flowchart LR 1. Add CultCacheTS-backed binary `.cc` persistence behind the current writer. 2. Add CultNet/CultMesh publication of the same documents. 3. Add command handling only through `gamecult.eve.command.v1`. -4. Add consentful CDP actions, starting with read-only page metadata and - operator-approved screenshot capture. +4. Add operator-approved screenshot capture. ## Command Boundary -Loki currently advertises no executable tab commands. This is deliberate. A -daemon that can evaluate JavaScript inside live user tabs is holding a sharp -tool; it needs an explicit command document, audit log, origin, target tab id, -and denial path before it should act. +Loki currently advertises no executable tab commands. Page snapshot capture is +extension-owned and popup-triggered: the operator clicks once, the extension +captures the active tab, posts the typed observation, and detaches. A future +daemon-originated command path still needs an explicit command document, audit +log, origin, target tab id, and denial path before it should act. diff --git a/extension/popup.css b/extension/popup.css index d199fa2..89bbf4a 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -56,7 +56,7 @@ dl { dl div { display: grid; - grid-template-columns: 72px 1fr; + grid-template-columns: 88px 1fr; gap: 8px; } diff --git a/extension/popup.html b/extension/popup.html index a60d8dd..88ca3e4 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -30,11 +30,16 @@
Debug
-
+
+
Page
+
-
+
+ diff --git a/extension/popup.js b/extension/popup.js index e0f7f1b..0dee208 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,8 +3,10 @@ const tabsEl = document.querySelector("#tabs"); const lastSyncEl = document.querySelector("#last-sync"); const endpointEl = document.querySelector("#endpoint"); const debugEl = document.querySelector("#debug"); +const pageSnapshotEl = document.querySelector("#page-snapshot"); const errorEl = document.querySelector("#error"); const syncButton = document.querySelector("#sync"); +const snapshotActiveTabButton = document.querySelector("#snapshot-active-tab"); const debugSweepButton = document.querySelector("#debug-sweep"); syncButton.addEventListener("click", async () => { @@ -21,6 +23,13 @@ debugSweepButton.addEventListener("click", async () => { debugSweepButton.disabled = false; }); +snapshotActiveTabButton.addEventListener("click", async () => { + snapshotActiveTabButton.disabled = true; + await chrome.runtime.sendMessage({ type: "loki.snapshot-active-tab" }); + await render(); + snapshotActiveTabButton.disabled = false; +}); + await render(); async function render() { @@ -33,6 +42,7 @@ async function render() { stateEl.className = "state"; tabsEl.textContent = "-"; debugEl.textContent = "-"; + pageSnapshotEl.textContent = "-"; lastSyncEl.textContent = "-"; errorEl.hidden = true; return; @@ -42,6 +52,7 @@ async function render() { stateEl.className = `state ${status.ok ? "ok" : "bad"}`; tabsEl.textContent = String(status.tabCount ?? 0); debugEl.textContent = formatDebugProbe(status.debugProbe); + pageSnapshotEl.textContent = formatPageSnapshot(status.pageSnapshot); lastSyncEl.textContent = status.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "-"; if (status.error) { @@ -57,3 +68,11 @@ function formatDebugProbe(debugProbe) { const summary = debugProbe.summary ?? {}; return `${summary.attachedTabCount ?? 0}/${summary.attemptedTabCount ?? 0} attached`; } + +function formatPageSnapshot(pageSnapshot) { + if (!pageSnapshot) return "not captured"; + if (pageSnapshot.error) return pageSnapshot.error; + const textLength = pageSnapshot.content?.visibleTextLength ?? 0; + const controlCount = pageSnapshot.forms?.controlCount ?? 0; + return `${textLength} chars, ${controlCount} controls`; +} diff --git a/extension/service-worker.js b/extension/service-worker.js index aa594f2..5a9fb86 100644 --- a/extension/service-worker.js +++ b/extension/service-worker.js @@ -35,6 +35,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { debugSweep().then(sendResponse); return true; } + if (message?.type === "loki.snapshot-active-tab") { + snapshotActiveTab().then(sendResponse); + return true; + } if (message?.type === "loki.get-status") { getStatus().then(sendResponse); return true; @@ -63,10 +67,11 @@ async function syncTabs(reason, extra = {}) { activeTabCount: tabs.filter((tab) => tab.active).length, }, redaction: { - pageContent: "not-read", + pageContent: extra.pageSnapshot ? "explicit-active-tab-visible-text" : "not-read", cookies: "not-read", tabMetadata: "title-url-favicon-window-state", debugProbe: extra.debugProbe ? "runtime-frame-performance-security-metadata" : "not-run", + pageSnapshot: extra.pageSnapshot ? extra.pageSnapshot.redaction : "not-run", }, ...extra, }; @@ -89,6 +94,7 @@ async function syncTabs(reason, extra = {}) { lastSyncAt: new Date().toISOString(), tabCount: tabs.length, debugProbe: responseBody.debugProbe ?? null, + pageSnapshot: responseBody.pageSnapshot ?? null, activeSource: responseBody.activeSource ?? "unknown", error: null, }; @@ -103,6 +109,7 @@ async function syncTabs(reason, extra = {}) { lastSyncAt: new Date().toISOString(), tabCount: 0, debugProbe: null, + pageSnapshot: null, activeSource: "none", error: error instanceof Error ? error.message : String(error), }; @@ -111,6 +118,164 @@ async function syncTabs(reason, extra = {}) { } } +async function snapshotActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const startedAt = new Date().toISOString(); + if (!tab || !Number.isInteger(tab.id)) { + return syncTabs("active-page-snapshot", { + pageSnapshot: failedPageSnapshot(startedAt, "No active tab is available."), + }); + } + + const pageSnapshot = await capturePageSnapshot(tab, startedAt); + return syncTabs("active-page-snapshot", { pageSnapshot }); +} + +async function capturePageSnapshot(tab, startedAt) { + const target = { tabId: tab.id }; + const pageSnapshot = { + schema: "gamecult.loki.chrome_page_snapshot.v0", + capturedAt: startedAt, + completedAt: null, + tab: projectTab(tab), + attached: false, + attachError: null, + error: null, + content: null, + forms: null, + redaction: { + trigger: "operator-popup-click", + scope: "active-tab-only", + pageContent: "visible-text-and-page-structure", + cookies: "not-read", + passwordValues: "redacted", + hiddenInputs: "omitted", + responseBodies: "not-read", + }, + }; + + try { + await chromeDebuggerAttach(target, "1.3"); + pageSnapshot.attached = true; + await chromeDebuggerSendCommand(target, "Runtime.enable"); + const result = await chromeDebuggerSendCommand(target, "Runtime.evaluate", { + expression: pageSnapshotExpression(), + returnByValue: true, + awaitPromise: true, + }); + const value = result?.result?.value; + if (!value || typeof value !== "object") { + throw new Error("Page snapshot did not return an object."); + } + pageSnapshot.content = value.content ?? null; + pageSnapshot.forms = value.forms ?? null; + } catch (error) { + pageSnapshot.error = error instanceof Error ? error.message : String(error); + if (!pageSnapshot.attached) pageSnapshot.attachError = pageSnapshot.error; + } finally { + pageSnapshot.completedAt = new Date().toISOString(); + if (pageSnapshot.attached) { + await chromeDebuggerDetach(target).catch(() => {}); + } + } + + return pageSnapshot; +} + +function failedPageSnapshot(startedAt, error) { + return { + schema: "gamecult.loki.chrome_page_snapshot.v0", + capturedAt: startedAt, + completedAt: new Date().toISOString(), + tab: null, + attached: false, + attachError: error, + error, + content: null, + forms: null, + redaction: { + trigger: "operator-popup-click", + scope: "active-tab-only", + pageContent: "not-read", + cookies: "not-read", + passwordValues: "not-read", + hiddenInputs: "not-read", + responseBodies: "not-read", + }, + }; +} + +function pageSnapshotExpression() { + return `(() => { + const limit = (value, max) => String(value ?? "").replace(/\\s+/g, " ").trim().slice(0, max); + const selectorFor = (element) => { + if (!element) return null; + if (element.id) return "#" + CSS.escape(element.id); + const name = element.getAttribute("name"); + if (name) return element.tagName.toLowerCase() + "[name=" + JSON.stringify(name) + "]"; + return element.tagName.toLowerCase(); + }; + const labelFor = (control) => { + const explicit = control.id ? document.querySelector("label[for=" + JSON.stringify(control.id) + "]") : null; + const implicit = control.closest("label"); + const aria = control.getAttribute("aria-label") || control.getAttribute("title") || control.getAttribute("placeholder"); + return limit(explicit?.innerText || implicit?.innerText || aria || "", 300); + }; + const controls = Array.from(document.querySelectorAll("input, select, textarea, button")) + .filter((control) => { + const type = (control.getAttribute("type") || control.tagName).toLowerCase(); + return type !== "hidden" && type !== "password"; + }) + .slice(0, 300) + .map((control) => { + const tag = control.tagName.toLowerCase(); + const type = (control.getAttribute("type") || tag).toLowerCase(); + const option = tag === "select" ? control.selectedOptions?.[0] : null; + const value = type === "checkbox" || type === "radio" + ? String(Boolean(control.checked)) + : tag === "select" + ? limit(option?.innerText || option?.label || "", 500) + : limit(control.value || "", 500); + return { + tag, + type, + label: labelFor(control), + selector: selectorFor(control), + name: control.getAttribute("name"), + id: control.id || null, + disabled: Boolean(control.disabled), + required: Boolean(control.required), + value, + }; + }); + const links = Array.from(document.links).slice(0, 200).map((link) => ({ + text: limit(link.innerText || link.textContent || "", 300), + href: link.href, + })); + const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).slice(0, 80).map((heading) => ({ + level: heading.tagName.toLowerCase(), + text: limit(heading.innerText || heading.textContent || "", 500), + })); + const visibleText = limit(document.body?.innerText || "", 60000); + return { + content: { + href: location.href, + title: document.title, + readyState: document.readyState, + language: document.documentElement.lang || null, + visibleText, + visibleTextLength: visibleText.length, + headings, + links, + }, + forms: { + controlCount: controls.length, + controls, + }, + }; + })()`; +} + async function debugSweep() { const startedAt = new Date().toISOString(); const tabs = await chrome.tabs.query({}); diff --git a/src/loki-daemon.mjs b/src/loki-daemon.mjs index 2557984..1d6108e 100644 --- a/src/loki-daemon.mjs +++ b/src/loki-daemon.mjs @@ -152,6 +152,7 @@ function normalizeExtensionObservation(payload, config) { maxAgeMs: config.intervalMs * 3, }, debugProbe: normalizeDebugProbe(payload.debugProbe), + pageSnapshot: normalizePageSnapshot(payload.pageSnapshot), tabs, }; } @@ -166,6 +167,16 @@ function normalizeDebugProbe(debugProbe) { }; } +function normalizePageSnapshot(pageSnapshot) { + if (!pageSnapshot || pageSnapshot.schema !== "gamecult.loki.chrome_page_snapshot.v0") return null; + return { + ...pageSnapshot, + providerId, + serviceId, + ingestedAt: new Date().toISOString(), + }; +} + function normalizeExtensionTab(tab) { return { id: String(tab.id ?? ""), @@ -234,6 +245,7 @@ function buildSnapshot(config, cdpObservation, extensionObservation) { cdp, extension, debugProbe: extensionObservation?.debugProbe ?? null, + pageSnapshot: extensionObservation?.pageSnapshot ?? null, tabs, summary: { tabCount: tabs.length, @@ -241,14 +253,17 @@ function buildSnapshot(config, cdpObservation, extensionObservation) { activeTabCount: tabs.filter((tab) => tab.active).length, debugProbeAttachedTabCount: extensionObservation?.debugProbe?.summary?.attachedTabCount ?? 0, debugProbeAttemptedTabCount: extensionObservation?.debugProbe?.summary?.attemptedTabCount ?? 0, + pageSnapshotVisibleTextLength: extensionObservation?.pageSnapshot?.content?.visibleTextLength ?? 0, + pageSnapshotFormControlCount: extensionObservation?.pageSnapshot?.forms?.controlCount ?? 0, }, redaction: { urlQuery: activeObservation ? "preserved" : "not-observed", titles: activeObservation ? "preserved" : "not-observed", - pageContent: "not-read", + pageContent: extensionObservation?.pageSnapshot ? "explicit-active-tab-visible-text" : "not-read", cookies: "not-read", source: activeObservation?.source ?? "none", debugProbe: extensionObservation?.debugProbe ? extensionObservation.debugProbe.redaction : "not-run", + pageSnapshot: extensionObservation?.pageSnapshot ? extensionObservation.pageSnapshot.redaction : "not-run", }, }; } @@ -287,18 +302,24 @@ function unreachableSnapshot(config, error) { error: "no extension report received", }, tabs: [], - summary: { - tabCount: 0, - inspectableTabCount: 0, - activeTabCount: 0, - }, - redaction: { - urlQuery: "not-observed", - titles: "not-observed", - pageContent: "not-read", - cookies: "not-read", - source: "none", - }, + pageSnapshot: null, + debugProbe: null, + summary: { + tabCount: 0, + inspectableTabCount: 0, + activeTabCount: 0, + pageSnapshotVisibleTextLength: 0, + pageSnapshotFormControlCount: 0, + }, + redaction: { + urlQuery: "not-observed", + titles: "not-observed", + pageContent: "not-read", + cookies: "not-read", + source: "none", + pageSnapshot: "not-run", + debugProbe: "not-run", + }, }; } @@ -356,6 +377,14 @@ function buildProviderAdvertisement(snapshot) { cultMeshAddress: "asgard.localhost.loki.chrome/state/debug-probe", portable: true, }, + { + schema: "gamecult.loki.chrome_page_snapshot.v0", + owner: providerId, + authority: "operator-approved-observation", + storage: "cultcache-cc", + cultMeshAddress: "asgard.localhost.loki.chrome/state/page-snapshot", + portable: true, + }, { schema: "gamecult.eve.surface.v1", owner: providerId, @@ -399,6 +428,17 @@ function buildProviderAdvertisement(snapshot) { updatedAt: snapshot.debugProbe?.ingestedAt ?? null, }, }, + { + id: "loki.chrome.page_snapshot", + kind: "cc-export", + path: "state/loki.chrome_page_snapshot.cc", + schemas: ["gamecult.loki.chrome_page_snapshot.v0"], + redaction: snapshot.pageSnapshot?.redaction ?? "not-run", + freshness: { + state: snapshot.pageSnapshot ? "fresh" : "missing", + updatedAt: snapshot.pageSnapshot?.ingestedAt ?? null, + }, + }, ], surfaces: [ { @@ -424,8 +464,8 @@ function buildProviderAdvertisement(snapshot) { { kind: "chrome-extension-ingest", address: snapshot.extension.ingestEndpoint, - carries: ["chrome-extension-tab-metadata"], - note: "Preferred drop-in Chrome path; extension observes tab metadata and can run explicit chrome.debugger sweeps.", + carries: ["chrome-extension-tab-metadata", "explicit-active-page-snapshot"], + note: "Preferred drop-in Chrome path; extension observes tab metadata and can run explicit operator-triggered page snapshots and chrome.debugger sweeps.", }, { kind: "local-cultcache-witness", @@ -435,6 +475,7 @@ function buildProviderAdvertisement(snapshot) { "state/loki.provider_advertisement.cc", "state/loki.eve_surface.cc", "state/loki.chrome_debug_probe.cc", + "state/loki.chrome_page_snapshot.cc", ], }, ], @@ -496,6 +537,14 @@ function buildEveSurface(snapshot) { : "not run", snapshot.debugProbe ? "ok" : "muted", ), + metric( + "page-snapshot", + "Page snapshot", + snapshot.pageSnapshot + ? `${snapshot.summary.pageSnapshotVisibleTextLength} chars` + : "not captured", + snapshot.pageSnapshot ? "ok" : "muted", + ), { id: "loki.chrome.extension-endpoint", kind: "inspector.kv", @@ -614,6 +663,9 @@ async function publishSnapshot(config, snapshot) { snapshot.debugProbe ? writeTypedDocument(join(config.stateDir, "loki.chrome_debug_probe.cc"), snapshot.debugProbe) : Promise.resolve(), + snapshot.pageSnapshot + ? writeTypedDocument(join(config.stateDir, "loki.chrome_page_snapshot.cc"), snapshot.pageSnapshot) + : Promise.resolve(), ]); return { advertisement, surface }; @@ -674,6 +726,7 @@ function startExtensionIngestServer(config) { activeSource: snapshot.activeSource, tabs: snapshot.summary.tabCount, debugProbe: snapshot.debugProbe ? snapshot.debugProbe : null, + pageSnapshot: snapshot.pageSnapshot ? snapshot.pageSnapshot : null, observedAt: snapshot.observedAt, }); return; diff --git a/test/extension-ingest-smoke.mjs b/test/extension-ingest-smoke.mjs index 77fe212..528d516 100644 --- a/test/extension-ingest-smoke.mjs +++ b/test/extension-ingest-smoke.mjs @@ -74,6 +74,51 @@ try { runtimeEvaluation: "fixture", }, }, + pageSnapshot: { + schema: "gamecult.loki.chrome_page_snapshot.v0", + capturedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + tab: { + id: 100, + windowId: 1, + index: 0, + active: true, + highlighted: true, + pinned: false, + audible: false, + muted: false, + incognito: false, + title: "Portal das Financas", + url: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap", + favIconUrl: null, + }, + attached: true, + attachError: null, + error: null, + content: { + href: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap", + title: "Portal das Financas", + readyState: "complete", + language: "pt", + visibleText: "Entregar declaracao IRS Consultar pagamentos", + visibleTextLength: 43, + headings: [{ level: "h1", text: "Portal das Financas" }], + links: [{ text: "Consultar pagamentos", href: "https://sitfiscal.portaldasfinancas.gov.pt/pagamentos" }], + }, + forms: { + controlCount: 1, + controls: [{ tag: "button", type: "button", label: "", selector: "button", name: null, id: null, disabled: false, required: false, value: "" }], + }, + redaction: { + trigger: "operator-popup-click", + scope: "active-tab-only", + pageContent: "visible-text-and-page-structure", + cookies: "not-read", + passwordValues: "redacted", + hiddenInputs: "omitted", + responseBodies: "not-read", + }, + }, tabs: [ { id: 100, @@ -99,6 +144,7 @@ try { const snapshotText = await readFile(join(stateDir, "loki.chrome_snapshot.cc"), "utf8"); const debugProbeText = await readFile(join(stateDir, "loki.chrome_debug_probe.cc"), "utf8"); + const pageSnapshotText = await readFile(join(stateDir, "loki.chrome_page_snapshot.cc"), "utf8"); if (!snapshotText.includes('"activeSource": "chrome-extension"')) { throw new Error("snapshot did not choose chrome-extension as active source"); } @@ -108,6 +154,12 @@ try { if (!debugProbeText.includes('"schema": "gamecult.loki.chrome_debug_probe.v0"')) { throw new Error("debug probe witness was not written"); } + if (!pageSnapshotText.includes('"schema": "gamecult.loki.chrome_page_snapshot.v0"')) { + throw new Error("page snapshot witness was not written"); + } + if (!pageSnapshotText.includes("Entregar declaracao IRS")) { + throw new Error("page snapshot did not preserve visible page text"); + } console.log("extension ingest smoke passed"); } finally {