Add explicit Chrome debug sweep

This commit is contained in:
Meta Vens 2026-06-09 17:04:38 +01:00
commit fa3b9c7778
9 changed files with 320 additions and 8 deletions

View file

@ -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`

View file

@ -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.

View file

@ -5,6 +5,7 @@
"description": "Publishes Chrome tab metadata to the local Loki CultMesh daemon.",
"permissions": [
"alarms",
"debugger",
"storage",
"tabs"
],

View file

@ -85,6 +85,12 @@ button {
cursor: pointer;
}
button + button {
margin-top: 8px;
border-color: #34516f;
background: #386181;
}
button:disabled {
cursor: wait;
opacity: 0.65;

View file

@ -26,11 +26,16 @@
<dt>Endpoint</dt>
<dd id="endpoint">-</dd>
</div>
<div>
<dt>Debug</dt>
<dd id="debug">-</dd>
</div>
</dl>
<p id="error" class="error" hidden></p>
<button id="sync" type="button">Sync now</button>
<button id="debug-sweep" type="button">Debug sweep</button>
</main>
<script src="popup.js" type="module"></script>
</body>

View file

@ -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`;
}

View file

@ -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;

View file

@ -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;

View file

@ -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 {