diff --git a/README.md b/README.md index dd3471d..74333c5 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,14 @@ Then install the extension: 4. Select `E:\Projects\Loki\extension`. The extension posts tab metadata to -`http://127.0.0.1:17666/ingest/chrome-extension`. It does not read page DOM, -cookies, or page content. +`http://127.0.0.1:17666/ingest/chrome-extension`. Its normal sync does not read +page DOM, cookies, or page content. + +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/ +performance/security/root-DOM metadata, then detaches. It does not read response +bodies, cookies, form values, or full page DOM. ## CDP Observation @@ -65,6 +71,7 @@ npm run smoke Loki writes: - `state/loki.chrome_snapshot.cc` +- `state/loki.chrome_debug_probe.cc` - `state/loki.provider_advertisement.cc` - `state/loki.eve_surface.cc` diff --git a/docs/architecture.md b/docs/architecture.md index 1810888..0ee2ea6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,20 +8,24 @@ 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. Chrome can also expose -DevTools metadata on `127.0.0.1:{port}` when started with -`--remote-debugging-port`. Loki normalizes both sources into one snapshot and -writes three documents from that snapshot. +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 +`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. ```mermaid flowchart LR Extension["Chrome extension"] --> Ingest["extension ingest"] + Debugger["chrome.debugger sweep"] --> Ingest Chrome["Chrome CDP endpoint"] --> Observe["observe CDP"] Config["Loki config"] --> Ingest Config --> Observe Ingest --> Combine["combined snapshot"] Observe --> Combine Combine --> Snapshot["chrome_snapshot.cc"] + Combine --> Debug["chrome_debug_probe.cc"] Snapshot --> Provider["provider_advertisement.cc"] Snapshot --> Surface["eve_surface.cc"] ``` @@ -30,6 +34,7 @@ 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. - 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. diff --git a/extension/manifest.json b/extension/manifest.json index 03eb1c9..e3a3056 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -5,6 +5,7 @@ "description": "Publishes Chrome tab metadata to the local Loki CultMesh daemon.", "permissions": [ "alarms", + "debugger", "storage", "tabs" ], diff --git a/extension/popup.css b/extension/popup.css index baa15b4..d199fa2 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -85,6 +85,12 @@ button { cursor: pointer; } +button + button { + margin-top: 8px; + border-color: #34516f; + background: #386181; +} + button:disabled { cursor: wait; opacity: 0.65; diff --git a/extension/popup.html b/extension/popup.html index 9021511..a60d8dd 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -26,11 +26,16 @@
Endpoint
-
+
+
Debug
+
-
+
+ diff --git a/extension/popup.js b/extension/popup.js index 25b9c6a..e0f7f1b 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -2,8 +2,10 @@ const stateEl = document.querySelector("#state"); const tabsEl = document.querySelector("#tabs"); const lastSyncEl = document.querySelector("#last-sync"); const endpointEl = document.querySelector("#endpoint"); +const debugEl = document.querySelector("#debug"); const errorEl = document.querySelector("#error"); const syncButton = document.querySelector("#sync"); +const debugSweepButton = document.querySelector("#debug-sweep"); syncButton.addEventListener("click", async () => { syncButton.disabled = true; @@ -12,6 +14,13 @@ syncButton.addEventListener("click", async () => { syncButton.disabled = false; }); +debugSweepButton.addEventListener("click", async () => { + debugSweepButton.disabled = true; + await chrome.runtime.sendMessage({ type: "loki.debug-sweep" }); + await render(); + debugSweepButton.disabled = false; +}); + await render(); async function render() { @@ -23,6 +32,7 @@ async function render() { stateEl.textContent = "waiting"; stateEl.className = "state"; tabsEl.textContent = "-"; + debugEl.textContent = "-"; lastSyncEl.textContent = "-"; errorEl.hidden = true; return; @@ -31,6 +41,7 @@ async function render() { stateEl.textContent = status.ok ? "connected" : "offline"; stateEl.className = `state ${status.ok ? "ok" : "bad"}`; tabsEl.textContent = String(status.tabCount ?? 0); + debugEl.textContent = formatDebugProbe(status.debugProbe); lastSyncEl.textContent = status.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "-"; if (status.error) { @@ -40,3 +51,9 @@ async function render() { errorEl.hidden = true; } } + +function formatDebugProbe(debugProbe) { + if (!debugProbe) return "not run"; + const summary = debugProbe.summary ?? {}; + return `${summary.attachedTabCount ?? 0}/${summary.attemptedTabCount ?? 0} attached`; +} diff --git a/extension/service-worker.js b/extension/service-worker.js index f56b4e2..aa594f2 100644 --- a/extension/service-worker.js +++ b/extension/service-worker.js @@ -31,6 +31,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { syncTabs("manual").then(sendResponse); return true; } + if (message?.type === "loki.debug-sweep") { + debugSweep().then(sendResponse); + return true; + } if (message?.type === "loki.get-status") { getStatus().then(sendResponse); return true; @@ -42,7 +46,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return false; }); -async function syncTabs(reason) { +async function syncTabs(reason, extra = {}) { const statusStartedAt = new Date().toISOString(); try { const endpoint = await getEndpoint(); @@ -62,7 +66,9 @@ async function syncTabs(reason) { pageContent: "not-read", cookies: "not-read", tabMetadata: "title-url-favicon-window-state", + debugProbe: extra.debugProbe ? "runtime-frame-performance-security-metadata" : "not-run", }, + ...extra, }; const response = await fetch(endpoint, { @@ -82,6 +88,7 @@ async function syncTabs(reason) { reason, lastSyncAt: new Date().toISOString(), tabCount: tabs.length, + debugProbe: responseBody.debugProbe ?? null, activeSource: responseBody.activeSource ?? "unknown", error: null, }; @@ -95,6 +102,7 @@ async function syncTabs(reason) { reason, lastSyncAt: new Date().toISOString(), tabCount: 0, + debugProbe: null, activeSource: "none", error: error instanceof Error ? error.message : String(error), }; @@ -103,6 +111,142 @@ async function syncTabs(reason) { } } +async function debugSweep() { + const startedAt = new Date().toISOString(); + const tabs = await chrome.tabs.query({}); + const targets = await chromeDebuggerGetTargets(); + const probe = { + schema: "gamecult.loki.chrome_debug_probe.v0", + startedAt, + completedAt: null, + protocolVersion: "1.3", + targetCount: targets.length, + targets: targets.map(projectDebugTarget), + tabs: [], + summary: { + attemptedTabCount: 0, + attachedTabCount: 0, + failedTabCount: 0, + commandCount: 0, + commandFailureCount: 0, + }, + redaction: { + pageContent: "not-read", + cookies: "not-read", + responseBodies: "not-read", + domTree: "root-node-only", + runtimeEvaluation: "location-title-readyState-visibility-performance-navigator", + }, + }; + + for (const tab of tabs) { + if (!Number.isInteger(tab.id)) continue; + probe.summary.attemptedTabCount += 1; + const tabProbe = await probeTab(tab); + probe.tabs.push(tabProbe); + if (tabProbe.attached) probe.summary.attachedTabCount += 1; + else probe.summary.failedTabCount += 1; + probe.summary.commandCount += tabProbe.commands.length; + probe.summary.commandFailureCount += tabProbe.commands.filter((command) => !command.ok).length; + } + + probe.completedAt = new Date().toISOString(); + return syncTabs("debug-sweep", { debugProbe: probe }); +} + +async function probeTab(tab) { + const target = { tabId: tab.id }; + const tabProbe = { + tabId: tab.id, + windowId: tab.windowId, + title: tab.title ?? null, + url: tab.url ?? null, + attached: false, + attachError: null, + commands: [], + frameTree: null, + runtime: null, + performanceMetrics: null, + securityState: null, + domRoot: null, + }; + + try { + await chromeDebuggerAttach(target, "1.3"); + tabProbe.attached = true; + + await recordCommand(tabProbe, target, "Page.enable"); + await recordCommand(tabProbe, target, "Runtime.enable"); + await recordCommand(tabProbe, target, "Log.enable"); + await recordCommand(tabProbe, target, "Performance.enable"); + await recordCommand(tabProbe, target, "Security.enable"); + await recordCommand(tabProbe, target, "DOM.enable"); + + tabProbe.frameTree = await recordCommand(tabProbe, target, "Page.getFrameTree"); + tabProbe.runtime = await recordCommand(tabProbe, target, "Runtime.evaluate", { + expression: `({ + href: location.href, + title: document.title, + readyState: document.readyState, + visibilityState: document.visibilityState, + referrer: document.referrer, + origin: location.origin, + navigation: performance.getEntriesByType("navigation").map((entry) => entry.toJSON?.() ?? {}), + screen: { width: screen.width, height: screen.height, colorDepth: screen.colorDepth }, + navigator: { + userAgent: navigator.userAgent, + language: navigator.language, + languages: navigator.languages, + hardwareConcurrency: navigator.hardwareConcurrency, + deviceMemory: navigator.deviceMemory ?? null, + platform: navigator.platform, + cookieEnabled: navigator.cookieEnabled + } + })`, + returnByValue: true, + awaitPromise: false, + }); + tabProbe.performanceMetrics = await recordCommand(tabProbe, target, "Performance.getMetrics"); + tabProbe.securityState = await recordCommand(tabProbe, target, "Security.getSecurityState"); + tabProbe.domRoot = await recordCommand(tabProbe, target, "DOM.getDocument", { depth: 0, pierce: false }); + } catch (error) { + tabProbe.attachError = error instanceof Error ? error.message : String(error); + } finally { + if (tabProbe.attached) { + await chromeDebuggerDetach(target).catch(() => {}); + } + } + + return tabProbe; +} + +async function recordCommand(tabProbe, target, method, params = undefined) { + try { + const result = await chromeDebuggerSendCommand(target, method, params); + tabProbe.commands.push({ method, ok: true }); + return result ?? {}; + } catch (error) { + tabProbe.commands.push({ + method, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +function projectDebugTarget(target) { + return { + id: target.id ?? null, + tabId: target.tabId ?? null, + type: target.type ?? null, + title: target.title ?? null, + url: target.url ?? null, + attached: Boolean(target.attached), + extensionId: target.extensionId ?? null, + }; +} + function projectTab(tab) { return { id: tab.id, @@ -128,6 +272,46 @@ async function getStatus() { }; } +function chromeDebuggerGetTargets() { + return new Promise((resolve, reject) => { + chrome.debugger.getTargets((targets) => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message)); + else resolve(targets ?? []); + }); + }); +} + +function chromeDebuggerAttach(target, protocolVersion) { + return new Promise((resolve, reject) => { + chrome.debugger.attach(target, protocolVersion, () => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message)); + else resolve(); + }); + }); +} + +function chromeDebuggerSendCommand(target, method, params) { + return new Promise((resolve, reject) => { + chrome.debugger.sendCommand(target, method, params ?? {}, (result) => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message)); + else resolve(result); + }); + }); +} + +function chromeDebuggerDetach(target) { + return new Promise((resolve, reject) => { + chrome.debugger.detach(target, () => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message)); + else resolve(); + }); + }); +} + async function getEndpoint() { const values = await chrome.storage.local.get(CONFIG_KEY); return values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT; diff --git a/src/loki-daemon.mjs b/src/loki-daemon.mjs index d3bde2f..2557984 100644 --- a/src/loki-daemon.mjs +++ b/src/loki-daemon.mjs @@ -151,10 +151,21 @@ function normalizeExtensionObservation(payload, config) { lastSeenAt: observedAt, maxAgeMs: config.intervalMs * 3, }, + debugProbe: normalizeDebugProbe(payload.debugProbe), tabs, }; } +function normalizeDebugProbe(debugProbe) { + if (!debugProbe || debugProbe.schema !== "gamecult.loki.chrome_debug_probe.v0") return null; + return { + ...debugProbe, + providerId, + serviceId, + ingestedAt: new Date().toISOString(), + }; +} + function normalizeExtensionTab(tab) { return { id: String(tab.id ?? ""), @@ -222,11 +233,14 @@ function buildSnapshot(config, cdpObservation, extensionObservation) { }, cdp, extension, + debugProbe: extensionObservation?.debugProbe ?? null, tabs, summary: { tabCount: tabs.length, inspectableTabCount: tabs.filter((tab) => tab.webSocketDebuggerUrl).length, activeTabCount: tabs.filter((tab) => tab.active).length, + debugProbeAttachedTabCount: extensionObservation?.debugProbe?.summary?.attachedTabCount ?? 0, + debugProbeAttemptedTabCount: extensionObservation?.debugProbe?.summary?.attemptedTabCount ?? 0, }, redaction: { urlQuery: activeObservation ? "preserved" : "not-observed", @@ -234,6 +248,7 @@ function buildSnapshot(config, cdpObservation, extensionObservation) { pageContent: "not-read", cookies: "not-read", source: activeObservation?.source ?? "none", + debugProbe: extensionObservation?.debugProbe ? extensionObservation.debugProbe.redaction : "not-run", }, }; } @@ -333,6 +348,14 @@ function buildProviderAdvertisement(snapshot) { cultMeshAddress: "asgard.localhost.loki.chrome/ingest/chrome-extension", portable: true, }, + { + schema: "gamecult.loki.chrome_debug_probe.v0", + owner: providerId, + authority: "observed", + storage: "cultcache-cc", + cultMeshAddress: "asgard.localhost.loki.chrome/state/debug-probe", + portable: true, + }, { schema: "gamecult.eve.surface.v1", owner: providerId, @@ -365,6 +388,17 @@ function buildProviderAdvertisement(snapshot) { updatedAt, }, }, + { + id: "loki.chrome.debug_probe", + kind: "cc-export", + path: "state/loki.chrome_debug_probe.cc", + schemas: ["gamecult.loki.chrome_debug_probe.v0"], + redaction: snapshot.debugProbe?.redaction ?? "not-run", + freshness: { + state: snapshot.debugProbe ? "fresh" : "missing", + updatedAt: snapshot.debugProbe?.ingestedAt ?? null, + }, + }, ], surfaces: [ { @@ -391,7 +425,7 @@ 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 Loki owns persistence.", + note: "Preferred drop-in Chrome path; extension observes tab metadata and can run explicit chrome.debugger sweeps.", }, { kind: "local-cultcache-witness", @@ -400,6 +434,7 @@ function buildProviderAdvertisement(snapshot) { "state/loki.chrome_snapshot.cc", "state/loki.provider_advertisement.cc", "state/loki.eve_surface.cc", + "state/loki.chrome_debug_probe.cc", ], }, ], @@ -453,6 +488,14 @@ function buildEveSurface(snapshot) { metric("source", "Source", snapshot.activeSource, statusTone), metric("tabs", "Tabs", String(snapshot.summary.tabCount), "neutral"), metric("inspectable", "Inspectable", String(snapshot.summary.inspectableTabCount), "neutral"), + metric( + "debug-probe", + "Debug sweep", + snapshot.debugProbe + ? `${snapshot.summary.debugProbeAttachedTabCount}/${snapshot.summary.debugProbeAttemptedTabCount} tabs` + : "not run", + snapshot.debugProbe ? "ok" : "muted", + ), { id: "loki.chrome.extension-endpoint", kind: "inspector.kv", @@ -568,6 +611,9 @@ async function publishSnapshot(config, snapshot) { writeTypedDocument(join(config.stateDir, "loki.chrome_snapshot.cc"), snapshot), writeTypedDocument(join(config.stateDir, "loki.provider_advertisement.cc"), advertisement), writeTypedDocument(join(config.stateDir, "loki.eve_surface.cc"), surface), + snapshot.debugProbe + ? writeTypedDocument(join(config.stateDir, "loki.chrome_debug_probe.cc"), snapshot.debugProbe) + : Promise.resolve(), ]); return { advertisement, surface }; @@ -627,6 +673,7 @@ function startExtensionIngestServer(config) { schema: snapshot.schema, activeSource: snapshot.activeSource, tabs: snapshot.summary.tabCount, + debugProbe: snapshot.debugProbe ? snapshot.debugProbe : null, observedAt: snapshot.observedAt, }); return; diff --git a/test/extension-ingest-smoke.mjs b/test/extension-ingest-smoke.mjs index ef8e38b..77fe212 100644 --- a/test/extension-ingest-smoke.mjs +++ b/test/extension-ingest-smoke.mjs @@ -38,6 +38,42 @@ try { browser: "Chrome fixture", reportedAt: new Date().toISOString(), reason: "smoke", + debugProbe: { + schema: "gamecult.loki.chrome_debug_probe.v0", + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + protocolVersion: "1.3", + targetCount: 1, + targets: [{ id: "fixture-target", tabId: 100, type: "page", title: "Portal das Financas", url: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap", attached: false }], + tabs: [{ + tabId: 100, + windowId: 1, + title: "Portal das Financas", + url: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap", + attached: true, + attachError: null, + commands: [{ method: "Page.getFrameTree", ok: true }], + frameTree: { frameTree: { frame: { id: "frame-1", url: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap" } } }, + runtime: { result: { type: "object", value: { href: "https://sitfiscal.portaldasfinancas.gov.pt/geral/siteMap" } } }, + performanceMetrics: { metrics: [] }, + securityState: null, + domRoot: { root: { nodeId: 1, nodeName: "#document" } }, + }], + summary: { + attemptedTabCount: 1, + attachedTabCount: 1, + failedTabCount: 0, + commandCount: 1, + commandFailureCount: 0, + }, + redaction: { + pageContent: "not-read", + cookies: "not-read", + responseBodies: "not-read", + domTree: "root-node-only", + runtimeEvaluation: "fixture", + }, + }, tabs: [ { id: 100, @@ -62,12 +98,16 @@ try { } const snapshotText = await readFile(join(stateDir, "loki.chrome_snapshot.cc"), "utf8"); + const debugProbeText = await readFile(join(stateDir, "loki.chrome_debug_probe.cc"), "utf8"); if (!snapshotText.includes('"activeSource": "chrome-extension"')) { throw new Error("snapshot did not choose chrome-extension as active source"); } if (!snapshotText.includes("Portal das Financas")) { throw new Error("snapshot did not include extension tab"); } + if (!debugProbeText.includes('"schema": "gamecult.loki.chrome_debug_probe.v0"')) { + throw new Error("debug probe witness was not written"); + } console.log("extension ingest smoke passed"); } finally {