Add Loki Chrome extension bridge

This commit is contained in:
Meta Vens 2026-06-09 16:12:31 +01:00
commit ae87719d61
13 changed files with 863 additions and 70 deletions

View file

@ -1,16 +1,17 @@
# Loki # Loki
Loki is a CultMesh-oriented daemon for observing local Chrome tabs through an Loki is a CultMesh-oriented daemon for observing local Chrome tabs through a
explicit Chrome DevTools Protocol endpoint. 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 It does not see ordinary locked Chrome tabs by magic. The easy path is loading
with a remote debugging port, and the daemon publishes that reachability as the unpacked extension in `extension/`; the developer path is launching Chrome
state instead of pretending. with a remote debugging port. The daemon publishes reachability as state instead
of pretending.
## Authority Map ## Authority Map
- Owner: Loki owns Chrome DevTools observation and the derived operator surface. - 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. `http://127.0.0.1:9222/json/list`, and daemon configuration.
- Outputs: typed `.cc` witness documents, an Eve provider advertisement, and a - Outputs: typed `.cc` witness documents, an Eve provider advertisement, and a
`gamecult.eve.surface.v1` surface projection. `gamecult.eve.surface.v1` surface projection.
@ -18,12 +19,32 @@ state instead of pretending.
operator text are derived from the Chrome snapshot. operator text are derived from the Chrome snapshot.
- Forbidden writers: renderers, dashboards, ad hoc scripts, and browser windows - Forbidden writers: renderers, dashboards, ad hoc scripts, and browser windows
do not own Loki state. do not own Loki state.
- Shared paths: one `observeChrome()` path feeds witness, advertisement, and - Shared paths: extension and CDP observations are normalized into one snapshot
Eve projection. before witness, advertisement, and Eve projection.
- Cut line: command execution is not implemented until it has an auditable - Cut line: command execution is not implemented until it has an auditable
command envelope and explicit consent boundary. 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: 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" --user-data-dir="$env:TEMP\loki-chrome-profile"
``` ```
Then run Loki:
```powershell
cd E:\Projects\Loki
npm run start
```
For a one-shot smoke: For a one-shot smoke:
```powershell ```powershell

View file

@ -3,19 +3,25 @@
## Objective ## Objective
Expose local Chrome tab state as provider-owned, typed service state for 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 ## Current Mechanism
Chrome exposes DevTools metadata on `127.0.0.1:{port}` when started with The Chrome extension observes tab metadata with explicit Chrome permissions and
`--remote-debugging-port`. Loki polls the version and tab-list endpoints, posts reports to Loki's localhost ingest endpoint. Chrome can also expose
normalizes the result, and writes three documents from the same snapshot. 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 ```mermaid
flowchart LR flowchart LR
Chrome["Chrome CDP endpoint"] --> Observe["observeChrome"] Extension["Chrome extension"] --> Ingest["extension ingest"]
Config["Loki config"] --> Observe Chrome["Chrome CDP endpoint"] --> Observe["observe CDP"]
Observe --> Snapshot["chrome_snapshot.cc"] Config["Loki config"] --> Ingest
Config --> Observe
Ingest --> Combine["combined snapshot"]
Observe --> Combine
Combine --> Snapshot["chrome_snapshot.cc"]
Snapshot --> Provider["provider_advertisement.cc"] Snapshot --> Provider["provider_advertisement.cc"]
Snapshot --> Surface["eve_surface.cc"] Snapshot --> Surface["eve_surface.cc"]
``` ```
@ -23,6 +29,7 @@ flowchart LR
## Invariants ## Invariants
- CDP reachability is observed state, not assumed capability. - 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. - Chrome owns page reality; Loki owns only the observation snapshot.
- `.cc` witnesses are the durable local state surface. - `.cc` witnesses are the durable local state surface.
- Eve/CultUI output is derived from the same snapshot as the witness. - Eve/CultUI output is derived from the same snapshot as the witness.

23
extension/manifest.json Normal file
View file

@ -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"
}
}

91
extension/popup.css Normal file
View file

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

37
extension/popup.html Normal file
View file

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Loki Chrome Bridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<main>
<header>
<h1>Loki</h1>
<span id="state" class="state">checking</span>
</header>
<dl>
<div>
<dt>Tabs</dt>
<dd id="tabs">-</dd>
</div>
<div>
<dt>Last sync</dt>
<dd id="last-sync">-</dd>
</div>
<div>
<dt>Endpoint</dt>
<dd id="endpoint">-</dd>
</div>
</dl>
<p id="error" class="error" hidden></p>
<button id="sync" type="button">Sync now</button>
</main>
<script src="popup.js" type="module"></script>
</body>
</html>

42
extension/popup.js Normal file
View file

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

146
extension/service-worker.js Normal file
View file

@ -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:<port>/ingest/chrome-extension or localhost.",
};
}
await chrome.storage.local.set({ [CONFIG_KEY]: { endpoint: cleanEndpoint } });
return syncTabs("endpoint-updated");
}

View file

@ -7,7 +7,7 @@
"scripts": { "scripts": {
"start": "node src/loki-daemon.mjs", "start": "node src/loki-daemon.mjs",
"smoke": "node src/loki-daemon.mjs --once", "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": { "engines": {
"node": ">=20" "node": ">=20"

View file

@ -1,3 +1,4 @@
import { createServer } from "node:http";
import { mkdir, rename, writeFile } from "node:fs/promises"; import { mkdir, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -11,6 +12,8 @@ function parseArgs(argv) {
const config = { const config = {
host: process.env.LOKI_CHROME_HOST ?? "127.0.0.1", host: process.env.LOKI_CHROME_HOST ?? "127.0.0.1",
port: Number(process.env.LOKI_CHROME_PORT ?? 9222), 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), intervalMs: Number(process.env.LOKI_INTERVAL_MS ?? 5000),
stateDir: process.env.LOKI_STATE_DIR ?? defaultStateDir, stateDir: process.env.LOKI_STATE_DIR ?? defaultStateDir,
once: false, once: false,
@ -21,6 +24,8 @@ function parseArgs(argv) {
if (arg === "--once") config.once = true; if (arg === "--once") config.once = true;
else if (arg === "--host") config.host = argv[++index] ?? config.host; else if (arg === "--host") config.host = argv[++index] ?? config.host;
else if (arg === "--port") config.port = Number(argv[++index] ?? config.port); 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 === "--interval-ms") config.intervalMs = Number(argv[++index] ?? config.intervalMs);
else if (arg === "--state-dir") config.stateDir = argv[++index] ?? config.stateDir; else if (arg === "--state-dir") config.stateDir = argv[++index] ?? config.stateDir;
else if (arg === "--help") { else if (arg === "--help") {
@ -35,6 +40,9 @@ function parseArgs(argv) {
if (!Number.isFinite(config.intervalMs) || config.intervalMs < 1000) { if (!Number.isFinite(config.intervalMs) || config.intervalMs < 1000) {
throw new Error(`Invalid --interval-ms: ${config.intervalMs}`); 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; return config;
} }
@ -43,6 +51,7 @@ function printHelp() {
Usage: Usage:
node src/loki-daemon.mjs [--once] [--host 127.0.0.1] [--port 9222] 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] [--interval-ms 5000] [--state-dir state]
`); `);
} }
@ -79,15 +88,14 @@ async function observeChrome(config) {
: []; : [];
return { return {
schema: "gamecult.loki.chrome_snapshot.v0", source: "cdp",
providerId,
serviceId,
observedAt, observedAt,
freshness: { freshness: {
state: "fresh", state: "fresh",
lastSeenAt: observedAt, lastSeenAt: observedAt,
maxAgeMs: config.intervalMs * 3, maxAgeMs: config.intervalMs * 3,
}, },
authority: "external-authority-projection",
cdp: { cdp: {
endpoint: baseUrl, endpoint: baseUrl,
reachable: true, reachable: true,
@ -97,22 +105,10 @@ async function observeChrome(config) {
webSocketDebuggerUrl: stringOrNull(version.webSocketDebuggerUrl), webSocketDebuggerUrl: stringOrNull(version.webSocketDebuggerUrl),
}, },
tabs, 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) { } catch (error) {
return { return {
schema: "gamecult.loki.chrome_snapshot.v0", source: "cdp",
providerId,
serviceId,
observedAt, observedAt,
freshness: { freshness: {
state: "unreachable", state: "unreachable",
@ -125,18 +121,170 @@ async function observeChrome(config) {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}, },
tabs: [], 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: { summary: {
tabCount: 0, tabCount: 0,
inspectableTabCount: 0, inspectableTabCount: 0,
activeTabCount: 0,
}, },
redaction: { redaction: {
urlQuery: "not-observed", urlQuery: "not-observed",
titles: "not-observed", titles: "not-observed",
pageContent: "not-read", pageContent: "not-read",
cookies: "not-read", cookies: "not-read",
source: "none",
}, },
}; };
}
} }
function normalizeTab(target) { function normalizeTab(target) {
@ -177,6 +325,14 @@ function buildProviderAdvertisement(snapshot) {
cultMeshAddress: "asgard.localhost.loki.chrome/state/snapshot", cultMeshAddress: "asgard.localhost.loki.chrome/state/snapshot",
portable: true, 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", schema: "gamecult.eve.surface.v1",
owner: providerId, owner: providerId,
@ -231,6 +387,12 @@ function buildProviderAdvertisement(snapshot) {
carries: ["chrome-target-metadata"], carries: ["chrome-target-metadata"],
note: "Requires user-launched Chrome with --remote-debugging-port.", 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", kind: "local-cultcache-witness",
address: "state/", address: "state/",
@ -248,7 +410,7 @@ function buildProviderAdvertisement(snapshot) {
} }
function buildEveSurface(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) => ({ const tabRows = snapshot.tabs.slice(0, 20).map((tab) => ({
id: `tab-${stableId(tab.id)}`, id: `tab-${stableId(tab.id)}`,
kind: "inspector.kv", kind: "inspector.kv",
@ -285,22 +447,33 @@ function buildEveSurface(snapshot) {
{ {
id: "loki.chrome.summary", id: "loki.chrome.summary",
kind: "panel", kind: "panel",
props: { title: "Chrome DevTools", tone: statusTone }, props: { title: "Chrome Observation", tone: statusTone },
children: [ 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("tabs", "Tabs", String(snapshot.summary.tabCount), "neutral"),
metric("inspectable", "Inspectable", String(snapshot.summary.inspectableTabCount), "neutral"), metric("inspectable", "Inspectable", String(snapshot.summary.inspectableTabCount), "neutral"),
{ {
id: "loki.chrome.endpoint", id: "loki.chrome.extension-endpoint",
kind: "inspector.kv", kind: "inspector.kv",
props: { props: {
label: "Endpoint", label: "Extension ingest",
value: snapshot.cdp.endpoint, value: snapshot.extension.ingestEndpoint,
tone: statusTone, tone: snapshot.extension.reachable ? "ok" : "muted",
}, },
children: [], 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", id: "loki.chrome.error",
kind: "text", kind: "text",
@ -308,11 +481,11 @@ function buildEveSurface(snapshot) {
children: [], children: [],
} }
: { : {
id: "loki.chrome.browser", id: "loki.chrome.source-state",
kind: "inspector.kv", kind: "inspector.kv",
props: { props: {
label: "Browser", label: "Source state",
value: snapshot.cdp.browser ?? "unknown", value: `extension=${snapshot.sources.chromeExtension.state}; cdp=${snapshot.sources.cdp.state}`,
tone: "neutral", tone: "neutral",
}, },
children: [], children: [],
@ -368,6 +541,10 @@ function stringOrNull(value) {
return typeof value === "string" && value.length > 0 ? value : null; return typeof value === "string" && value.length > 0 ? value : null;
} }
function numberOrNull(value) {
return Number.isFinite(value) ? value : null;
}
async function writeTypedDocument(filePath, document) { async function writeTypedDocument(filePath, document) {
await mkdir(dirname(filePath), { recursive: true }); await mkdir(dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.tmp`; const tempPath = `${filePath}.${process.pid}.tmp`;
@ -397,11 +574,107 @@ async function publishSnapshot(config, snapshot) {
} }
async function tick(config) { 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); await publishSnapshot(config, snapshot);
const state = snapshot.freshness.state; const state = snapshot.freshness.state;
const count = snapshot.summary.tabCount; 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() { async function main() {
@ -409,6 +682,7 @@ async function main() {
await tick(config); await tick(config);
if (config.once) return; if (config.once) return;
startExtensionIngestServer(config);
setInterval(() => { setInterval(() => {
tick(config).catch((error) => { tick(config).catch((error) => {
console.error(error); console.error(error);

View file

@ -1,31 +1,49 @@
# cultcache-text-witness v0 # cultcache-text-witness v0
# schema: gamecult.loki.chrome_snapshot.v0 # schema: gamecult.loki.chrome_snapshot.v0
# provider: loki.chrome # provider: loki.chrome
# updatedAt: 2026-06-09T14:02:52.753Z # updatedAt: 2026-06-09T15:11:06.124Z
{ {
"schema": "gamecult.loki.chrome_snapshot.v0", "schema": "gamecult.loki.chrome_snapshot.v0",
"providerId": "loki.chrome", "providerId": "loki.chrome",
"serviceId": "loki.chrome_daemon", "serviceId": "loki.chrome_daemon",
"observedAt": "2026-06-09T14:02:52.753Z", "observedAt": "2026-06-09T15:11:06.124Z",
"activeSource": "none",
"freshness": { "freshness": {
"state": "unreachable", "state": "unreachable",
"lastSeenAt": null, "lastSeenAt": null,
"maxAgeMs": 15000 "maxAgeMs": 15000
}, },
"sources": {
"cdp": {
"state": "unreachable",
"observedAt": "2026-06-09T15:11:06.096Z"
},
"chromeExtension": {
"state": "missing",
"observedAt": null
}
},
"cdp": { "cdp": {
"endpoint": "http://127.0.0.1:9222", "endpoint": "http://127.0.0.1:9222",
"reachable": false, "reachable": false,
"error": "fetch failed" "error": "fetch failed"
}, },
"extension": {
"reachable": false,
"ingestEndpoint": "http://127.0.0.1:17666/ingest/chrome-extension",
"error": "no extension report received"
},
"tabs": [], "tabs": [],
"summary": { "summary": {
"tabCount": 0, "tabCount": 0,
"inspectableTabCount": 0 "inspectableTabCount": 0,
"activeTabCount": 0
}, },
"redaction": { "redaction": {
"urlQuery": "not-observed", "urlQuery": "not-observed",
"titles": "not-observed", "titles": "not-observed",
"pageContent": "not-read", "pageContent": "not-read",
"cookies": "not-read" "cookies": "not-read",
"source": "none"
} }
} }

View file

@ -1,15 +1,15 @@
# cultcache-text-witness v0 # cultcache-text-witness v0
# schema: gamecult.eve.surface.v1 # schema: gamecult.eve.surface.v1
# provider: loki.chrome # provider: loki.chrome
# updatedAt: 2026-06-09T14:02:52.753Z # updatedAt: 2026-06-09T15:11:06.124Z
{ {
"type": "surface-state", "type": "surface-state",
"schema": "gamecult.eve.surface.v1", "schema": "gamecult.eve.surface.v1",
"providerId": "loki.chrome", "providerId": "loki.chrome",
"providerKind": "service.operator", "providerKind": "service.operator",
"title": "Loki Chrome", "title": "Loki Chrome",
"version": 1781013772753, "version": 1781017866124,
"updatedAt": "2026-06-09T14:02:52.753Z", "updatedAt": "2026-06-09T15:11:06.124Z",
"surface": { "surface": {
"root": { "root": {
"id": "loki.chrome.root", "id": "loki.chrome.root",
@ -27,7 +27,7 @@
"id": "loki.chrome.summary", "id": "loki.chrome.summary",
"kind": "panel", "kind": "panel",
"props": { "props": {
"title": "Chrome DevTools", "title": "Chrome Observation",
"tone": "warning" "tone": "warning"
}, },
"children": [ "children": [
@ -41,6 +41,16 @@
}, },
"children": [] "children": []
}, },
{
"id": "loki.chrome.metric.source",
"kind": "metric",
"props": {
"label": "Source",
"value": "none",
"tone": "warning"
},
"children": []
},
{ {
"id": "loki.chrome.metric.tabs", "id": "loki.chrome.metric.tabs",
"kind": "metric", "kind": "metric",
@ -62,12 +72,22 @@
"children": [] "children": []
}, },
{ {
"id": "loki.chrome.endpoint", "id": "loki.chrome.extension-endpoint",
"kind": "inspector.kv", "kind": "inspector.kv",
"props": { "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", "value": "http://127.0.0.1:9222",
"tone": "warning" "tone": "muted"
}, },
"children": [] "children": []
}, },

View file

@ -1,7 +1,7 @@
# cultcache-text-witness v0 # cultcache-text-witness v0
# schema: gamecult.eve.provider_advertisement.v1 # schema: gamecult.eve.provider_advertisement.v1
# provider: loki.chrome # provider: loki.chrome
# updatedAt: 2026-06-09T14:02:52.753Z # updatedAt: 2026-06-09T15:11:06.124Z
{ {
"schema": "gamecult.eve.provider_advertisement.v1", "schema": "gamecult.eve.provider_advertisement.v1",
"providerId": "loki.chrome", "providerId": "loki.chrome",
@ -13,7 +13,7 @@
"cultMeshAddress": "asgard.localhost.loki.chrome/eve/gui", "cultMeshAddress": "asgard.localhost.loki.chrome/eve/gui",
"title": "Loki Chrome", "title": "Loki Chrome",
"kind": "service.operator", "kind": "service.operator",
"updatedAt": "2026-06-09T14:02:52.753Z", "updatedAt": "2026-06-09T15:11:06.124Z",
"freshness": { "freshness": {
"state": "unreachable", "state": "unreachable",
"lastSeenAt": null, "lastSeenAt": null,
@ -28,6 +28,14 @@
"cultMeshAddress": "asgard.localhost.loki.chrome/state/snapshot", "cultMeshAddress": "asgard.localhost.loki.chrome/state/snapshot",
"portable": true "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", "schema": "gamecult.eve.surface.v1",
"owner": "loki.chrome", "owner": "loki.chrome",
@ -49,11 +57,12 @@
"urlQuery": "not-observed", "urlQuery": "not-observed",
"titles": "not-observed", "titles": "not-observed",
"pageContent": "not-read", "pageContent": "not-read",
"cookies": "not-read" "cookies": "not-read",
"source": "none"
}, },
"freshness": { "freshness": {
"state": "unreachable", "state": "unreachable",
"updatedAt": "2026-06-09T14:02:52.753Z" "updatedAt": "2026-06-09T15:11:06.124Z"
} }
}, },
{ {
@ -66,7 +75,7 @@
"redaction": "derived-operator-surface", "redaction": "derived-operator-surface",
"freshness": { "freshness": {
"state": "unreachable", "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." "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", "kind": "local-cultcache-witness",
"address": "state/", "address": "state/",

View file

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