From ae87719d61489d44775a6d97c94bfc514a8a1a2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 9 Jun 2026 16:12:31 +0100 Subject: [PATCH] Add Loki Chrome extension bridge --- README.md | 46 ++-- docs/architecture.md | 21 +- extension/manifest.json | 23 ++ extension/popup.css | 91 ++++++++ extension/popup.html | 37 +++ extension/popup.js | 42 ++++ extension/service-worker.js | 146 ++++++++++++ package.json | 2 +- src/loki-daemon.mjs | 336 ++++++++++++++++++++++++--- state/loki.chrome_snapshot.cc | 26 ++- state/loki.eve_surface.cc | 34 ++- state/loki.provider_advertisement.cc | 27 ++- test/extension-ingest-smoke.mjs | 104 +++++++++ 13 files changed, 864 insertions(+), 71 deletions(-) create mode 100644 extension/manifest.json create mode 100644 extension/popup.css create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 extension/service-worker.js create mode 100644 test/extension-ingest-smoke.mjs diff --git a/README.md b/README.md index 34d639d..dd3471d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # Loki -Loki is a CultMesh-oriented daemon for observing local Chrome tabs through an -explicit Chrome DevTools Protocol endpoint. +Loki is a CultMesh-oriented daemon for observing local Chrome tabs through a +drop-in Chrome extension or an explicit Chrome DevTools Protocol endpoint. -It does not see ordinary locked Chrome tabs by magic. Chrome must be launched -with a remote debugging port, and the daemon publishes that reachability as -state instead of pretending. +It does not see ordinary locked Chrome tabs by magic. The easy path is loading +the unpacked extension in `extension/`; the developer path is launching Chrome +with a remote debugging port. The daemon publishes reachability as state instead +of pretending. ## Authority Map - Owner: Loki owns Chrome DevTools observation and the derived operator surface. -- Inputs: `http://127.0.0.1:9222/json/version`, +- Inputs: Chrome extension reports, `http://127.0.0.1:9222/json/version`, `http://127.0.0.1:9222/json/list`, and daemon configuration. - Outputs: typed `.cc` witness documents, an Eve provider advertisement, and a `gamecult.eve.surface.v1` surface projection. @@ -18,12 +19,32 @@ state instead of pretending. operator text are derived from the Chrome snapshot. - Forbidden writers: renderers, dashboards, ad hoc scripts, and browser windows do not own Loki state. -- Shared paths: one `observeChrome()` path feeds witness, advertisement, and - Eve projection. +- Shared paths: extension and CDP observations are normalized into one snapshot + before witness, advertisement, and Eve projection. - Cut line: command execution is not implemented until it has an auditable command envelope and explicit consent boundary. -## Start Chrome For Observation +## Drop Into Chrome + +Run Loki: + +```powershell +cd E:\Projects\Loki +npm run start +``` + +Then install the extension: + +1. Open `chrome://extensions`. +2. Enable `Developer mode`. +3. Choose `Load unpacked`. +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. + +## CDP Observation Close Chrome or use a separate profile, then launch: @@ -33,13 +54,6 @@ Close Chrome or use a separate profile, then launch: --user-data-dir="$env:TEMP\loki-chrome-profile" ``` -Then run Loki: - -```powershell -cd E:\Projects\Loki -npm run start -``` - For a one-shot smoke: ```powershell diff --git a/docs/architecture.md b/docs/architecture.md index 0b06efe..1810888 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,19 +3,25 @@ ## Objective Expose local Chrome tab state as provider-owned, typed service state for -CultMesh/Eve consumers. +CultMesh/Eve consumers while keeping Chrome installation easy for the operator. ## Current Mechanism -Chrome exposes DevTools metadata on `127.0.0.1:{port}` when started with -`--remote-debugging-port`. Loki polls the version and tab-list endpoints, -normalizes the result, and writes three documents from the same snapshot. +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. ```mermaid flowchart LR - Chrome["Chrome CDP endpoint"] --> Observe["observeChrome"] - Config["Loki config"] --> Observe - Observe --> Snapshot["chrome_snapshot.cc"] + Extension["Chrome extension"] --> Ingest["extension 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"] Snapshot --> Provider["provider_advertisement.cc"] Snapshot --> Surface["eve_surface.cc"] ``` @@ -23,6 +29,7 @@ flowchart LR ## Invariants - CDP reachability is observed state, not assumed capability. +- Extension reachability is observed state, not assumed capability. - 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 new file mode 100644 index 0000000..03eb1c9 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "Loki Chrome Bridge", + "version": "0.1.0", + "description": "Publishes Chrome tab metadata to the local Loki CultMesh daemon.", + "permissions": [ + "alarms", + "storage", + "tabs" + ], + "host_permissions": [ + "http://127.0.0.1:17666/*", + "http://localhost:17666/*" + ], + "background": { + "service_worker": "service-worker.js", + "type": "module" + }, + "action": { + "default_title": "Loki Chrome Bridge", + "default_popup": "popup.html" + } +} diff --git a/extension/popup.css b/extension/popup.css new file mode 100644 index 0000000..baa15b4 --- /dev/null +++ b/extension/popup.css @@ -0,0 +1,91 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 300px; + font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #17201b; + background: #f7faf8; +} + +main { + padding: 12px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +h1 { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.state { + border: 1px solid #a9b8ae; + border-radius: 999px; + padding: 2px 8px; + color: #445149; + background: #ffffff; +} + +.state.ok { + border-color: #58a36f; + color: #1d6b35; + background: #eef8f0; +} + +.state.bad { + border-color: #c2803a; + color: #7a4a13; + background: #fff7ea; +} + +dl { + display: grid; + gap: 8px; + margin: 0 0 12px; +} + +dl div { + display: grid; + grid-template-columns: 72px 1fr; + gap: 8px; +} + +dt { + color: #5a665f; +} + +dd { + margin: 0; + overflow-wrap: anywhere; +} + +.error { + margin: 0 0 12px; + color: #8d3d20; +} + +button { + width: 100%; + border: 1px solid #2b6b47; + border-radius: 6px; + padding: 7px 10px; + color: #ffffff; + background: #2f7a50; + font: inherit; + cursor: pointer; +} + +button:disabled { + cursor: wait; + opacity: 0.65; +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..9021511 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,37 @@ + + + + + + Loki Chrome Bridge + + + +
+
+

Loki

+ checking +
+ +
+
+
Tabs
+
-
+
+
+
Last sync
+
-
+
+
+
Endpoint
+
-
+
+
+ + + + +
+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..25b9c6a --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,42 @@ +const stateEl = document.querySelector("#state"); +const tabsEl = document.querySelector("#tabs"); +const lastSyncEl = document.querySelector("#last-sync"); +const endpointEl = document.querySelector("#endpoint"); +const errorEl = document.querySelector("#error"); +const syncButton = document.querySelector("#sync"); + +syncButton.addEventListener("click", async () => { + syncButton.disabled = true; + await chrome.runtime.sendMessage({ type: "loki.sync-now" }); + await render(); + syncButton.disabled = false; +}); + +await render(); + +async function render() { + const result = await chrome.runtime.sendMessage({ type: "loki.get-status" }); + const status = result.status; + endpointEl.textContent = result.endpoint; + + if (!status) { + stateEl.textContent = "waiting"; + stateEl.className = "state"; + tabsEl.textContent = "-"; + lastSyncEl.textContent = "-"; + errorEl.hidden = true; + return; + } + + stateEl.textContent = status.ok ? "connected" : "offline"; + stateEl.className = `state ${status.ok ? "ok" : "bad"}`; + tabsEl.textContent = String(status.tabCount ?? 0); + lastSyncEl.textContent = status.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "-"; + + if (status.error) { + errorEl.textContent = status.error; + errorEl.hidden = false; + } else { + errorEl.hidden = true; + } +} diff --git a/extension/service-worker.js b/extension/service-worker.js new file mode 100644 index 0000000..f56b4e2 --- /dev/null +++ b/extension/service-worker.js @@ -0,0 +1,146 @@ +const DEFAULT_ENDPOINT = "http://127.0.0.1:17666/ingest/chrome-extension"; +const STATUS_KEY = "lokiStatus"; +const CONFIG_KEY = "lokiConfig"; + +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create("loki-sync", { periodInMinutes: 1 }); + syncTabs("installed"); +}); + +chrome.runtime.onStartup.addListener(() => { + chrome.alarms.create("loki-sync", { periodInMinutes: 1 }); + syncTabs("startup"); +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "loki-sync") syncTabs("alarm"); +}); + +chrome.tabs.onCreated.addListener(() => syncTabs("tab-created")); +chrome.tabs.onUpdated.addListener(() => syncTabs("tab-updated")); +chrome.tabs.onRemoved.addListener(() => syncTabs("tab-removed")); +chrome.tabs.onActivated.addListener(() => syncTabs("tab-activated")); +chrome.tabs.onMoved.addListener(() => syncTabs("tab-moved")); +chrome.tabs.onAttached.addListener(() => syncTabs("tab-attached")); +chrome.tabs.onDetached.addListener(() => syncTabs("tab-detached")); +chrome.tabs.onHighlighted.addListener(() => syncTabs("tab-highlighted")); +chrome.windows.onFocusChanged.addListener(() => syncTabs("window-focus")); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === "loki.sync-now") { + syncTabs("manual").then(sendResponse); + return true; + } + if (message?.type === "loki.get-status") { + getStatus().then(sendResponse); + return true; + } + if (message?.type === "loki.set-endpoint") { + setEndpoint(message.endpoint).then(sendResponse); + return true; + } + return false; +}); + +async function syncTabs(reason) { + const statusStartedAt = new Date().toISOString(); + try { + const endpoint = await getEndpoint(); + const tabs = await chrome.tabs.query({}); + const report = { + schema: "gamecult.loki.chrome_extension_report.v0", + extensionVersion: chrome.runtime.getManifest().version, + browser: navigator.userAgent, + reportedAt: statusStartedAt, + reason, + tabs: tabs.map(projectTab), + summary: { + tabCount: tabs.length, + activeTabCount: tabs.filter((tab) => tab.active).length, + }, + redaction: { + pageContent: "not-read", + cookies: "not-read", + tabMetadata: "title-url-favicon-window-state", + }, + }; + + const response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(report), + }); + + const responseBody = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(responseBody.error || `HTTP ${response.status}`); + } + + const status = { + ok: true, + endpoint, + reason, + lastSyncAt: new Date().toISOString(), + tabCount: tabs.length, + activeSource: responseBody.activeSource ?? "unknown", + error: null, + }; + await chrome.storage.local.set({ [STATUS_KEY]: status }); + return status; + } catch (error) { + const endpoint = await getEndpoint(); + const status = { + ok: false, + endpoint, + reason, + lastSyncAt: new Date().toISOString(), + tabCount: 0, + activeSource: "none", + error: error instanceof Error ? error.message : String(error), + }; + await chrome.storage.local.set({ [STATUS_KEY]: status }); + return status; + } +} + +function projectTab(tab) { + return { + id: tab.id, + windowId: tab.windowId, + index: tab.index, + active: tab.active, + highlighted: tab.highlighted, + pinned: tab.pinned, + audible: tab.audible ?? false, + muted: Boolean(tab.mutedInfo?.muted), + incognito: tab.incognito, + title: tab.title ?? null, + url: tab.url ?? null, + favIconUrl: tab.favIconUrl ?? null, + }; +} + +async function getStatus() { + const values = await chrome.storage.local.get([STATUS_KEY, CONFIG_KEY]); + return { + endpoint: values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT, + status: values[STATUS_KEY] ?? null, + }; +} + +async function getEndpoint() { + const values = await chrome.storage.local.get(CONFIG_KEY); + return values[CONFIG_KEY]?.endpoint ?? DEFAULT_ENDPOINT; +} + +async function setEndpoint(endpoint) { + const cleanEndpoint = String(endpoint || "").trim(); + if (!/^https?:\/\/(127\.0\.0\.1|localhost):\d+\/ingest\/chrome-extension$/.test(cleanEndpoint)) { + return { + ok: false, + error: "Endpoint must be http://127.0.0.1:/ingest/chrome-extension or localhost.", + }; + } + await chrome.storage.local.set({ [CONFIG_KEY]: { endpoint: cleanEndpoint } }); + return syncTabs("endpoint-updated"); +} diff --git a/package.json b/package.json index 48a80ad..1b55a10 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node src/loki-daemon.mjs", "smoke": "node src/loki-daemon.mjs --once", - "test": "node test/fixture-cdp-smoke.mjs" + "test": "node test/fixture-cdp-smoke.mjs && node test/extension-ingest-smoke.mjs" }, "engines": { "node": ">=20" diff --git a/src/loki-daemon.mjs b/src/loki-daemon.mjs index 7ba4602..d3bde2f 100644 --- a/src/loki-daemon.mjs +++ b/src/loki-daemon.mjs @@ -1,3 +1,4 @@ +import { createServer } from "node:http"; import { mkdir, rename, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,6 +12,8 @@ function parseArgs(argv) { const config = { host: process.env.LOKI_CHROME_HOST ?? "127.0.0.1", port: Number(process.env.LOKI_CHROME_PORT ?? 9222), + extensionHost: process.env.LOKI_EXTENSION_HOST ?? "127.0.0.1", + extensionPort: Number(process.env.LOKI_EXTENSION_PORT ?? 17666), intervalMs: Number(process.env.LOKI_INTERVAL_MS ?? 5000), stateDir: process.env.LOKI_STATE_DIR ?? defaultStateDir, once: false, @@ -21,6 +24,8 @@ function parseArgs(argv) { if (arg === "--once") config.once = true; else if (arg === "--host") config.host = argv[++index] ?? config.host; else if (arg === "--port") config.port = Number(argv[++index] ?? config.port); + else if (arg === "--extension-host") config.extensionHost = argv[++index] ?? config.extensionHost; + else if (arg === "--extension-port") config.extensionPort = Number(argv[++index] ?? config.extensionPort); else if (arg === "--interval-ms") config.intervalMs = Number(argv[++index] ?? config.intervalMs); else if (arg === "--state-dir") config.stateDir = argv[++index] ?? config.stateDir; else if (arg === "--help") { @@ -35,6 +40,9 @@ function parseArgs(argv) { if (!Number.isFinite(config.intervalMs) || config.intervalMs < 1000) { throw new Error(`Invalid --interval-ms: ${config.intervalMs}`); } + if (!Number.isFinite(config.extensionPort) || config.extensionPort <= 0) { + throw new Error(`Invalid --extension-port: ${config.extensionPort}`); + } return config; } @@ -43,6 +51,7 @@ function printHelp() { Usage: node src/loki-daemon.mjs [--once] [--host 127.0.0.1] [--port 9222] + [--extension-host 127.0.0.1] [--extension-port 17666] [--interval-ms 5000] [--state-dir state] `); } @@ -79,15 +88,14 @@ async function observeChrome(config) { : []; return { - schema: "gamecult.loki.chrome_snapshot.v0", - providerId, - serviceId, + source: "cdp", observedAt, freshness: { state: "fresh", lastSeenAt: observedAt, maxAgeMs: config.intervalMs * 3, }, + authority: "external-authority-projection", cdp: { endpoint: baseUrl, reachable: true, @@ -97,22 +105,10 @@ async function observeChrome(config) { webSocketDebuggerUrl: stringOrNull(version.webSocketDebuggerUrl), }, tabs, - summary: { - tabCount: tabs.length, - inspectableTabCount: tabs.filter((tab) => tab.webSocketDebuggerUrl).length, - }, - redaction: { - urlQuery: "preserved", - titles: "preserved", - pageContent: "not-read", - cookies: "not-read", - }, }; } catch (error) { return { - schema: "gamecult.loki.chrome_snapshot.v0", - providerId, - serviceId, + source: "cdp", observedAt, freshness: { state: "unreachable", @@ -125,18 +121,170 @@ async function observeChrome(config) { error: error instanceof Error ? error.message : String(error), }, tabs: [], + }; + } +} + +function normalizeExtensionObservation(payload, config) { + const observedAt = new Date().toISOString(); + if (!payload || payload.schema !== "gamecult.loki.chrome_extension_report.v0") { + throw new Error("Unsupported extension report schema"); + } + + const tabs = Array.isArray(payload.tabs) + ? payload.tabs.map(normalizeExtensionTab).sort((left, right) => Number(right.active) - Number(left.active) || left.index - right.index) + : []; + + return { + source: "chrome-extension", + observedAt, + extension: { + reachable: true, + extensionVersion: stringOrNull(payload.extensionVersion), + browser: stringOrNull(payload.browser), + receivedAt: observedAt, + lastReportAt: stringOrNull(payload.reportedAt), + ingestEndpoint: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`, + }, + freshness: { + state: "fresh", + lastSeenAt: observedAt, + maxAgeMs: config.intervalMs * 3, + }, + tabs, + }; +} + +function normalizeExtensionTab(tab) { + return { + id: String(tab.id ?? ""), + windowId: numberOrNull(tab.windowId), + index: Number.isFinite(tab.index) ? tab.index : 0, + active: Boolean(tab.active), + highlighted: Boolean(tab.highlighted), + pinned: Boolean(tab.pinned), + audible: Boolean(tab.audible), + muted: Boolean(tab.muted), + incognito: Boolean(tab.incognito), + title: stringOrNull(tab.title), + url: stringOrNull(tab.url), + type: "page", + faviconUrl: stringOrNull(tab.favIconUrl), + devtoolsFrontendUrl: null, + webSocketDebuggerUrl: null, + attached: false, + canInspect: false, + source: "chrome-extension", + }; +} + +function buildSnapshot(config, cdpObservation, extensionObservation) { + const observedAt = new Date().toISOString(); + const activeObservation = chooseActiveObservation(cdpObservation, extensionObservation); + const tabs = activeObservation?.tabs ?? []; + const cdp = cdpObservation?.cdp ?? { + endpoint: `http://${config.host}:${config.port}`, + reachable: false, + error: "not-observed", + }; + const extension = extensionObservation?.extension ?? { + reachable: false, + ingestEndpoint: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`, + error: "no extension report received", + }; + + const sourceStates = { + cdp: cdpObservation?.freshness?.state ?? "missing", + chromeExtension: extensionObservation?.freshness?.state ?? "missing", + }; + const freshnessState = activeObservation ? "fresh" : "unreachable"; + + return { + schema: "gamecult.loki.chrome_snapshot.v0", + providerId, + serviceId, + observedAt, + activeSource: activeObservation?.source ?? "none", + freshness: { + state: freshnessState, + lastSeenAt: activeObservation?.observedAt ?? null, + maxAgeMs: config.intervalMs * 3, + }, + sources: { + cdp: { + state: sourceStates.cdp, + observedAt: cdpObservation?.observedAt ?? null, + }, + chromeExtension: { + state: sourceStates.chromeExtension, + observedAt: extensionObservation?.observedAt ?? null, + }, + }, + cdp, + extension, + tabs, + summary: { + tabCount: tabs.length, + inspectableTabCount: tabs.filter((tab) => tab.webSocketDebuggerUrl).length, + activeTabCount: tabs.filter((tab) => tab.active).length, + }, + redaction: { + urlQuery: activeObservation ? "preserved" : "not-observed", + titles: activeObservation ? "preserved" : "not-observed", + pageContent: "not-read", + cookies: "not-read", + source: activeObservation?.source ?? "none", + }, + }; +} + +function chooseActiveObservation(cdpObservation, extensionObservation) { + if (extensionObservation?.freshness?.state === "fresh") return extensionObservation; + if (cdpObservation?.freshness?.state === "fresh") return cdpObservation; + return null; +} + +function unreachableSnapshot(config, error) { + const observedAt = new Date().toISOString(); + return { + schema: "gamecult.loki.chrome_snapshot.v0", + providerId, + serviceId, + observedAt, + activeSource: "none", + freshness: { + state: "unreachable", + lastSeenAt: null, + maxAgeMs: config.intervalMs * 3, + }, + sources: { + cdp: { state: "unreachable", observedAt }, + chromeExtension: { state: "missing", observedAt: null }, + }, + cdp: { + endpoint: `http://${config.host}:${config.port}`, + reachable: false, + error, + }, + extension: { + reachable: false, + ingestEndpoint: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`, + 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", }, - }; - } + }; } function normalizeTab(target) { @@ -177,6 +325,14 @@ function buildProviderAdvertisement(snapshot) { cultMeshAddress: "asgard.localhost.loki.chrome/state/snapshot", portable: true, }, + { + schema: "gamecult.loki.chrome_extension_report.v0", + owner: providerId, + authority: "external-authority-projection", + storage: "ingest-only", + cultMeshAddress: "asgard.localhost.loki.chrome/ingest/chrome-extension", + portable: true, + }, { schema: "gamecult.eve.surface.v1", owner: providerId, @@ -231,6 +387,12 @@ function buildProviderAdvertisement(snapshot) { carries: ["chrome-target-metadata"], note: "Requires user-launched Chrome with --remote-debugging-port.", }, + { + 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.", + }, { kind: "local-cultcache-witness", address: "state/", @@ -248,7 +410,7 @@ function buildProviderAdvertisement(snapshot) { } function buildEveSurface(snapshot) { - const statusTone = snapshot.cdp.reachable ? "ok" : "warning"; + const statusTone = snapshot.freshness.state === "fresh" ? "ok" : "warning"; const tabRows = snapshot.tabs.slice(0, 20).map((tab) => ({ id: `tab-${stableId(tab.id)}`, kind: "inspector.kv", @@ -285,22 +447,33 @@ function buildEveSurface(snapshot) { { id: "loki.chrome.summary", kind: "panel", - props: { title: "Chrome DevTools", tone: statusTone }, + props: { title: "Chrome Observation", tone: statusTone }, children: [ - metric("reachable", "Reachable", snapshot.cdp.reachable ? "yes" : "no", statusTone), + metric("reachable", "Reachable", snapshot.freshness.state === "fresh" ? "yes" : "no", statusTone), + metric("source", "Source", snapshot.activeSource, statusTone), metric("tabs", "Tabs", String(snapshot.summary.tabCount), "neutral"), metric("inspectable", "Inspectable", String(snapshot.summary.inspectableTabCount), "neutral"), { - id: "loki.chrome.endpoint", + id: "loki.chrome.extension-endpoint", kind: "inspector.kv", props: { - label: "Endpoint", - value: snapshot.cdp.endpoint, - tone: statusTone, + label: "Extension ingest", + value: snapshot.extension.ingestEndpoint, + tone: snapshot.extension.reachable ? "ok" : "muted", }, children: [], }, - snapshot.cdp.error + { + id: "loki.chrome.cdp-endpoint", + kind: "inspector.kv", + props: { + label: "CDP endpoint", + value: snapshot.cdp.endpoint, + tone: snapshot.cdp.reachable ? "ok" : "muted", + }, + children: [], + }, + snapshot.cdp.error && !snapshot.extension.reachable ? { id: "loki.chrome.error", kind: "text", @@ -308,11 +481,11 @@ function buildEveSurface(snapshot) { children: [], } : { - id: "loki.chrome.browser", + id: "loki.chrome.source-state", kind: "inspector.kv", props: { - label: "Browser", - value: snapshot.cdp.browser ?? "unknown", + label: "Source state", + value: `extension=${snapshot.sources.chromeExtension.state}; cdp=${snapshot.sources.cdp.state}`, tone: "neutral", }, children: [], @@ -368,6 +541,10 @@ function stringOrNull(value) { return typeof value === "string" && value.length > 0 ? value : null; } +function numberOrNull(value) { + return Number.isFinite(value) ? value : null; +} + async function writeTypedDocument(filePath, document) { await mkdir(dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.tmp`; @@ -397,11 +574,107 @@ async function publishSnapshot(config, snapshot) { } async function tick(config) { - const snapshot = await observeChrome(config); + const cdpObservation = await observeChrome(config); + const snapshot = buildSnapshot(config, cdpObservation, runtime.extensionObservation); await publishSnapshot(config, snapshot); const state = snapshot.freshness.state; const count = snapshot.summary.tabCount; - console.log(`${snapshot.observedAt} ${state} tabs=${count} endpoint=${snapshot.cdp.endpoint}`); + runtime.lastSnapshot = snapshot; + console.log(`${snapshot.observedAt} ${state} source=${snapshot.activeSource} tabs=${count} cdp=${snapshot.sources.cdp.state} extension=${snapshot.sources.chromeExtension.state}`); +} + +const runtime = { + extensionObservation: null, + lastSnapshot: null, +}; + +function startExtensionIngestServer(config) { + const server = createServer(async (request, response) => { + setCorsHeaders(response); + + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + try { + if (request.method === "GET" && request.url === "/health") { + writeJson(response, 200, { + ok: true, + providerId, + serviceId, + ingest: `http://${config.extensionHost}:${config.extensionPort}/ingest/chrome-extension`, + lastSnapshotAt: runtime.lastSnapshot?.observedAt ?? null, + }); + return; + } + + if (request.method === "GET" && request.url === "/state") { + writeJson(response, 200, runtime.lastSnapshot ?? unreachableSnapshot(config, "no snapshot published yet")); + return; + } + + if (request.method === "POST" && request.url === "/ingest/chrome-extension") { + const payload = await readJsonBody(request); + runtime.extensionObservation = normalizeExtensionObservation(payload, config); + const cdpObservation = await observeChrome(config); + const snapshot = buildSnapshot(config, cdpObservation, runtime.extensionObservation); + await publishSnapshot(config, snapshot); + runtime.lastSnapshot = snapshot; + writeJson(response, 202, { + accepted: true, + schema: snapshot.schema, + activeSource: snapshot.activeSource, + tabs: snapshot.summary.tabCount, + observedAt: snapshot.observedAt, + }); + return; + } + + writeJson(response, 404, { error: "not found" }); + } catch (error) { + writeJson(response, 400, { error: error instanceof Error ? error.message : String(error) }); + } + }); + + server.listen(config.extensionPort, config.extensionHost, () => { + console.log(`Loki extension ingest listening on http://${config.extensionHost}:${config.extensionPort}`); + }); + return server; +} + +function setCorsHeaders(response) { + response.setHeader("access-control-allow-origin", "*"); + response.setHeader("access-control-allow-methods", "GET,POST,OPTIONS"); + response.setHeader("access-control-allow-headers", "content-type"); +} + +function writeJson(response, statusCode, body) { + response.writeHead(statusCode, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function readJsonBody(request) { + return new Promise((resolve, reject) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + if (body.length > 1024 * 1024) { + request.destroy(); + reject(new Error("request body too large")); + } + }); + request.on("end", () => { + try { + resolve(JSON.parse(body || "{}")); + } catch (error) { + reject(error); + } + }); + request.on("error", reject); + }); } async function main() { @@ -409,6 +682,7 @@ async function main() { await tick(config); if (config.once) return; + startExtensionIngestServer(config); setInterval(() => { tick(config).catch((error) => { console.error(error); diff --git a/state/loki.chrome_snapshot.cc b/state/loki.chrome_snapshot.cc index d60a605..d1a1fdc 100644 --- a/state/loki.chrome_snapshot.cc +++ b/state/loki.chrome_snapshot.cc @@ -1,31 +1,49 @@ # cultcache-text-witness v0 # schema: gamecult.loki.chrome_snapshot.v0 # provider: loki.chrome -# updatedAt: 2026-06-09T14:02:52.753Z +# updatedAt: 2026-06-09T15:11:06.124Z { "schema": "gamecult.loki.chrome_snapshot.v0", "providerId": "loki.chrome", "serviceId": "loki.chrome_daemon", - "observedAt": "2026-06-09T14:02:52.753Z", + "observedAt": "2026-06-09T15:11:06.124Z", + "activeSource": "none", "freshness": { "state": "unreachable", "lastSeenAt": null, "maxAgeMs": 15000 }, + "sources": { + "cdp": { + "state": "unreachable", + "observedAt": "2026-06-09T15:11:06.096Z" + }, + "chromeExtension": { + "state": "missing", + "observedAt": null + } + }, "cdp": { "endpoint": "http://127.0.0.1:9222", "reachable": false, "error": "fetch failed" }, + "extension": { + "reachable": false, + "ingestEndpoint": "http://127.0.0.1:17666/ingest/chrome-extension", + "error": "no extension report received" + }, "tabs": [], "summary": { "tabCount": 0, - "inspectableTabCount": 0 + "inspectableTabCount": 0, + "activeTabCount": 0 }, "redaction": { "urlQuery": "not-observed", "titles": "not-observed", "pageContent": "not-read", - "cookies": "not-read" + "cookies": "not-read", + "source": "none" } } diff --git a/state/loki.eve_surface.cc b/state/loki.eve_surface.cc index 69c8773..f4451ca 100644 --- a/state/loki.eve_surface.cc +++ b/state/loki.eve_surface.cc @@ -1,15 +1,15 @@ # cultcache-text-witness v0 # schema: gamecult.eve.surface.v1 # provider: loki.chrome -# updatedAt: 2026-06-09T14:02:52.753Z +# updatedAt: 2026-06-09T15:11:06.124Z { "type": "surface-state", "schema": "gamecult.eve.surface.v1", "providerId": "loki.chrome", "providerKind": "service.operator", "title": "Loki Chrome", - "version": 1781013772753, - "updatedAt": "2026-06-09T14:02:52.753Z", + "version": 1781017866124, + "updatedAt": "2026-06-09T15:11:06.124Z", "surface": { "root": { "id": "loki.chrome.root", @@ -27,7 +27,7 @@ "id": "loki.chrome.summary", "kind": "panel", "props": { - "title": "Chrome DevTools", + "title": "Chrome Observation", "tone": "warning" }, "children": [ @@ -41,6 +41,16 @@ }, "children": [] }, + { + "id": "loki.chrome.metric.source", + "kind": "metric", + "props": { + "label": "Source", + "value": "none", + "tone": "warning" + }, + "children": [] + }, { "id": "loki.chrome.metric.tabs", "kind": "metric", @@ -62,12 +72,22 @@ "children": [] }, { - "id": "loki.chrome.endpoint", + "id": "loki.chrome.extension-endpoint", "kind": "inspector.kv", "props": { - "label": "Endpoint", + "label": "Extension ingest", + "value": "http://127.0.0.1:17666/ingest/chrome-extension", + "tone": "muted" + }, + "children": [] + }, + { + "id": "loki.chrome.cdp-endpoint", + "kind": "inspector.kv", + "props": { + "label": "CDP endpoint", "value": "http://127.0.0.1:9222", - "tone": "warning" + "tone": "muted" }, "children": [] }, diff --git a/state/loki.provider_advertisement.cc b/state/loki.provider_advertisement.cc index fa7e965..41a3dfd 100644 --- a/state/loki.provider_advertisement.cc +++ b/state/loki.provider_advertisement.cc @@ -1,7 +1,7 @@ # cultcache-text-witness v0 # schema: gamecult.eve.provider_advertisement.v1 # provider: loki.chrome -# updatedAt: 2026-06-09T14:02:52.753Z +# updatedAt: 2026-06-09T15:11:06.124Z { "schema": "gamecult.eve.provider_advertisement.v1", "providerId": "loki.chrome", @@ -13,7 +13,7 @@ "cultMeshAddress": "asgard.localhost.loki.chrome/eve/gui", "title": "Loki Chrome", "kind": "service.operator", - "updatedAt": "2026-06-09T14:02:52.753Z", + "updatedAt": "2026-06-09T15:11:06.124Z", "freshness": { "state": "unreachable", "lastSeenAt": null, @@ -28,6 +28,14 @@ "cultMeshAddress": "asgard.localhost.loki.chrome/state/snapshot", "portable": true }, + { + "schema": "gamecult.loki.chrome_extension_report.v0", + "owner": "loki.chrome", + "authority": "external-authority-projection", + "storage": "ingest-only", + "cultMeshAddress": "asgard.localhost.loki.chrome/ingest/chrome-extension", + "portable": true + }, { "schema": "gamecult.eve.surface.v1", "owner": "loki.chrome", @@ -49,11 +57,12 @@ "urlQuery": "not-observed", "titles": "not-observed", "pageContent": "not-read", - "cookies": "not-read" + "cookies": "not-read", + "source": "none" }, "freshness": { "state": "unreachable", - "updatedAt": "2026-06-09T14:02:52.753Z" + "updatedAt": "2026-06-09T15:11:06.124Z" } }, { @@ -66,7 +75,7 @@ "redaction": "derived-operator-surface", "freshness": { "state": "unreachable", - "updatedAt": "2026-06-09T14:02:52.753Z" + "updatedAt": "2026-06-09T15:11:06.124Z" } } ], @@ -93,6 +102,14 @@ ], "note": "Requires user-launched Chrome with --remote-debugging-port." }, + { + "kind": "chrome-extension-ingest", + "address": "http://127.0.0.1:17666/ingest/chrome-extension", + "carries": [ + "chrome-extension-tab-metadata" + ], + "note": "Preferred drop-in Chrome path; extension observes tab metadata and Loki owns persistence." + }, { "kind": "local-cultcache-witness", "address": "state/", diff --git a/test/extension-ingest-smoke.mjs b/test/extension-ingest-smoke.mjs new file mode 100644 index 0000000..ef8e38b --- /dev/null +++ b/test/extension-ingest-smoke.mjs @@ -0,0 +1,104 @@ +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawn } from "node:child_process"; + +const stateDir = await mkdtemp(join(tmpdir(), "loki-extension-smoke-")); +const extensionPort = await reservePort(); +const cdpPort = await reservePort(); +const daemon = spawn(process.execPath, [ + "src/loki-daemon.mjs", + "--port", + String(cdpPort), + "--extension-port", + String(extensionPort), + "--interval-ms", + "60000", + "--state-dir", + stateDir, +], { + cwd: new URL("..", import.meta.url), + stdio: "pipe", +}); + +let stderr = ""; +daemon.stderr.on("data", (chunk) => { + stderr += chunk; +}); + +try { + await waitForHealth(extensionPort); + const response = await fetch(`http://127.0.0.1:${extensionPort}/ingest/chrome-extension`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + schema: "gamecult.loki.chrome_extension_report.v0", + extensionVersion: "fixture", + browser: "Chrome fixture", + reportedAt: new Date().toISOString(), + reason: "smoke", + tabs: [ + { + 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, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`ingest failed: HTTP ${response.status} ${await response.text()}`); + } + + const snapshotText = await readFile(join(stateDir, "loki.chrome_snapshot.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"); + } + + console.log("extension ingest smoke passed"); +} finally { + daemon.kill(); + await rm(stateDir, { recursive: true, force: true }); +} + +async function waitForHealth(port) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + if (response.ok) return; + } catch { + await delay(100); + } + } + throw new Error(`daemon did not become healthy: ${stderr}`); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function reservePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +}