Add Loki Chrome extension bridge
This commit is contained in:
parent
c6188d4228
commit
ae87719d61
13 changed files with 863 additions and 70 deletions
46
README.md
46
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
23
extension/manifest.json
Normal 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
91
extension/popup.css
Normal 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
37
extension/popup.html
Normal 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
42
extension/popup.js
Normal 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
146
extension/service-worker.js
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": []
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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/",
|
||||||
|
|
|
||||||
104
test/extension-ingest-smoke.mjs
Normal file
104
test/extension-ingest-smoke.mjs
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in a new issue