Files
ARTEX/extension/background.js
BaaRai d8f47d4359 Traffic ingest: capture operator browsing via a browser extension
Closes #33. The MITM proxy only sees the agent's own actions, so
anything behind a human gate (signup captcha, email OTP, 2FA, SSO)
left the authenticated surface dark. The operator's browser becomes a
capture source instead:

- traffic: exported RecordExternal storing an externally captured
  exchange through the same spill/blob/FTS path as proxy traffic
  (insert logic shared with record()); rejects unparsable URLs and
  empty methods
- server: POST /api/traffic/ingest inside the authenticated API group,
  batch capped at 50 entries, bodies truncated at 256 KiB, per-entry
  ingested/failed counts
- extension/: Manifest V3 Chrome/Edge extension driving the
  chrome.debugger Network domain on the chosen tab, batching text
  exchanges to the ingest endpoint with the operator's URL + JWT in
  chrome.storage.local; binary bodies skipped; flush on batch/stop
- tests for both layers, README section
2026-10-11 14:53:08 +02:00

235 lines
7.5 KiB
JavaScript

// ARTEX Traffic Capture — background service worker.
//
// Captures the network activity of one tab through the chrome.debugger API
// (Network domain) and pushes text request/response pairs to the ARTEX
// ingest endpoint in batches. State lives in chrome.storage.session so the
// popup and the (short-lived) service worker share one view; the queue is
// deliberately in-memory — a browser restart drops unsent entries, which is
// the acceptable failure mode for a manual capture session.
const FLUSH_AT = 10; // entries per batch
const MAX_BODY = 256 * 1024; // matches the server-side ingest cap
const SKIP_MIME = ["image/", "font/", "audio/", "video/", "application/octet-stream"];
let session = {
capturing: false,
tabId: null,
queue: [], // pending ExternalExchange objects
requests: new Map(), // debugger requestId -> partial request info
ingested: 0,
failed: 0,
lastError: "",
};
async function saveState() {
await chrome.storage.session.set({
capturing: session.capturing,
tabId: session.tabId,
ingested: session.ingested,
failed: session.failed,
lastError: session.lastError,
queued: session.queue.length,
});
}
async function getConfig() {
const { serverUrl = "", token = "" } = await chrome.storage.local.get(["serverUrl", "token"]);
return { serverUrl: serverUrl.replace(/\/+$/, ""), token };
}
async function getActiveTabId() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab ? tab.id : null;
}
async function start() {
if (session.capturing) return { ok: true };
const { serverUrl, token } = await getConfig();
if (!serverUrl || !token) {
return { ok: false, error: "Set the ARTEX URL and token first." };
}
const tabId = await getActiveTabId();
if (tabId == null) return { ok: false, error: "No active tab." };
try {
await chrome.debugger.attach({ tabId }, "1.3");
} catch (e) {
return { ok: false, error: `attach failed: ${e.message}` };
}
await chrome.debugger.sendCommand({ tabId }, "Network.enable", {});
session = { ...session, capturing: true, tabId, queue: [], requests: new Map(), ingested: 0, failed: 0, lastError: "" };
chrome.action.setBadgeText({ text: "REC" });
chrome.action.setBadgeBackgroundColor({ color: "#c2410c" });
await saveState();
return { ok: true };
}
async function stop() {
if (session.capturing && session.tabId != null) {
await flush();
try {
await chrome.debugger.sendCommand({ tabId: session.tabId }, "Network.disable", {});
await chrome.debugger.detach({ tabId: session.tabId });
} catch (_) {
// tab may already be gone; nothing to do
}
}
session.capturing = false;
session.tabId = null;
chrome.action.setBadgeText({ text: "" });
await saveState();
return { ok: true };
}
function skipBody(mimeType) {
const mt = (mimeType || "").toLowerCase();
return SKIP_MIME.some((p) => mt.startsWith(p));
}
function truncate(s) {
if (s.length <= MAX_BODY) return s;
return s.slice(0, MAX_BODY) + "\n... [truncated by extension]";
}
async function queueExchange(info, response) {
const respHeaders = {};
for (const h of response.headers || []) respHeaders[h.name.toLowerCase()] = h.value;
const mime = respHeaders["content-type"] || "";
let reqBody = info.postData || "";
let respBody = "";
if (!skipBody(mime)) {
try {
const body = await chrome.debugger.sendCommand(
{ tabId: session.tabId },
"Network.getResponseBody",
{ requestId: info.requestId }
);
if (body && !body.base64Encoded) {
reqBody = truncate(reqBody);
respBody = truncate(body.body || "");
}
} catch (_) {
// body already discarded by the browser; capture the pair without bodies
}
}
session.queue.push({
method: info.method,
url: info.url,
status: response.status || 0,
requestHeaders: info.headers || {},
requestBody: reqBody,
responseHeaders: respHeaders,
responseBody: respBody,
});
if (session.queue.length >= FLUSH_AT) await flush();
}
async function flush() {
if (session.queue.length === 0) return;
const { serverUrl, token } = await getConfig();
if (!serverUrl || !token) {
session.lastError = "missing server URL or token";
await saveState();
return;
}
const batch = session.queue.splice(0, FLUSH_AT);
try {
const res = await fetch(`${serverUrl}/api/traffic/ingest`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ exchanges: batch }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
const out = await res.json();
session.ingested += out.ingested || 0;
session.failed += out.failed || 0;
session.lastError = out.failed ? `${out.failed} entries rejected by the server` : "";
} catch (e) {
// put the batch back (oldest first) but cap the queue so a dead server
// cannot grow memory unbounded
session.queue = batch.concat(session.queue).slice(0, 200);
session.lastError = e.message;
}
await saveState();
}
chrome.debugger.onEvent.addListener((source, method, params) => {
if (!session.capturing || !source.tabId || source.tabId !== session.tabId) return;
if (method === "Network.requestWillBeSent") {
session.requests.set(params.requestId, {
requestId: params.requestId,
method: params.request.method,
url: params.request.url,
headers: params.request.headers,
postData: params.request.postData || "",
});
} else if (method === "Network.responseReceived") {
const info = session.requests.get(params.requestId);
if (info) info.response = params.response;
} else if (method === "Network.loadingFinished") {
const info = session.requests.get(params.requestId);
session.requests.delete(params.requestId);
if (!info || !info.response) return;
queueExchange(info, info.response).catch((e) => {
session.lastError = e.message;
saveState();
});
}
});
chrome.debugger.onDetach.addListener((source) => {
if (session.capturing && source.tabId === session.tabId) {
// DevTools opened, or the tab navigated somewhere undebaggable: stop
// cleanly instead of silently dropping events.
session.capturing = false;
session.tabId = null;
session.lastError = "debugger detached (DevTools opened?)";
chrome.action.setBadgeText({ text: "" });
saveState();
}
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
(async () => {
switch (msg?.type) {
case "getState": {
sendResponse({
capturing: session.capturing,
tabId: session.tabId,
ingested: session.ingested,
failed: session.failed,
queued: session.queue.length,
lastError: session.lastError,
configured: !!(await getConfig()).serverUrl,
});
return;
}
case "start":
sendResponse(await start());
return;
case "stop":
sendResponse(await stop());
return;
case "flushNow":
await flush();
sendResponse(await stateSnapshot());
return;
default:
sendResponse({ ok: false, error: "unknown message" });
}
})();
return true; // async sendResponse
});
async function stateSnapshot() {
return {
capturing: session.capturing,
tabId: session.tabId,
ingested: session.ingested,
failed: session.failed,
queued: session.queue.length,
lastError: session.lastError,
};
}