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
+
+
+
+
+
+
+
+
+
- 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));
+ });
+ });
+}