From b011eca2033d132b5ebeb2cc44035441673dff6b Mon Sep 17 00:00:00 2001 From: Ruhollah Majdoddin Date: Tue, 11 Aug 2026 23:34:58 +0200 Subject: [PATCH] =?UTF-8?q?Bug=202062803=20-=20[firefox-devtools-mcp]=20Sh?= =?UTF-8?q?ip=20the=20=5F=5Fffllm=20kit:=20agents=20develop,=20debug,=20ho?= =?UTF-8?q?t-patch=20and=20verify=20a=20running=20Firefox=20=E2=80=94=20no?= =?UTF-8?q?=20rebuild?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This work was done with AI assistance; I have reviewed and can explain every line. --- kit/call.js | 151 +++++++++++++ kit/capture.js | 264 ++++++++++++++++++++++ kit/child.js | 159 +++++++++++++ kit/hook.js | 165 ++++++++++++++ kit/hookscript.js | 233 +++++++++++++++++++ kit/introspect.js | 167 ++++++++++++++ kit/loader.js | 37 +++ kit/recipe-rejection-observer.md | 103 +++++++++ kit/tap.js | 143 ++++++++++++ scripts/generate-moz-package.mjs | 2 +- src/config/constants.ts | 7 + src/index.ts | 28 ++- src/tools/firefox-management.ts | 2 +- src/tools/privileged-context.ts | 117 +++++++++- src/utils/kit.ts | 111 +++++++++ tests/integration/kit.integration.test.ts | 175 ++++++++++++++ tests/tools/privileged-context.test.ts | 40 ++++ tests/utils/kit.test.ts | 47 ++++ 18 files changed, 1944 insertions(+), 7 deletions(-) create mode 100644 kit/call.js create mode 100644 kit/capture.js create mode 100644 kit/child.js create mode 100644 kit/hook.js create mode 100644 kit/hookscript.js create mode 100644 kit/introspect.js create mode 100644 kit/loader.js create mode 100644 kit/recipe-rejection-observer.md create mode 100644 kit/tap.js create mode 100644 src/utils/kit.ts create mode 100644 tests/integration/kit.integration.test.ts create mode 100644 tests/utils/kit.test.ts diff --git a/kit/call.js b/kit/call.js new file mode 100644 index 00000000..5572d839 --- /dev/null +++ b/kit/call.js @@ -0,0 +1,151 @@ +// ff-llm primitive: callChild — evaluate in a content process, return the value. +// +// eval, one process over. The agent's eval channel terminates in the parent: a +// function goes in, its return value comes back, a returned promise is awaited. +// callChild is the same contract across the process boundary: src is the source +// of a function, evaluated in a child's persistent kit scope; its return value +// rides one message home. Streams are hookChild's job — callChild asks one +// question and is gone. +// +// await callChild(pid, src, opts) -> the function's return value +// await callChild('all', src, opts) -> [{ pid, value | error | timeout }] +// src source text of () => value, async fine. Runs inside the child's +// persistent ffllm-call sandbox (Services, Cc/Ci/Cu, ChromeUtils, +// setTimeout available). The value rides a message, so it must be +// structured-cloneable: JSON-shaped data survives every hop +// losslessly; project anything else to plain data or a string +// yourself, in the child, where the object is alive and you know +// its faithful reduction. A child throw rejects the pid form with +// the child's message and stack; 'all' reports it as { error }. +// opts { timeoutMs }, default 5000. The pid form rejects on timeout; +// 'all' resolves with { pid, timeout: true } for each silent +// process — who did not answer is evidence, not noise. +// +// The scope persists, the call does not. Each call ships one non-delayed +// process script: nothing stays armed, there is no teardown. But all calls +// into a process evaluate in one sandbox, minted on first use — stash on +// globalThis in this call, query it in the next, exactly as the parent realm +// carries state between evals. Keep an undo beside anything you install there; +// evidence that accrues over time belongs to hookChild, not to a stash. A +// process that dies takes its scope and stashes with it — re-plant on the next +// call, or use hookChild targets: 'all' when survival across churn matters. +// +// A snapshot with a deadline: the call reaches processes alive at dispatch — +// nothing re-arms for ones spawned later, and an unknown pid throws, naming +// the pids that exist ('all' is also the cheap process map). The timeout is a +// parent-side give-up, not an abort: the child evaluation runs on and its +// side effects land in the scope, where a follow-up call can inspect them; a +// late reply arrives on a message name nobody holds and is dropped — each +// call mints its own, so it can never leak into another call's answer. +// Validated Nightly 155.0a1, runs/callchild/validation-2026-08-09.md. +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + + // The process script, as a string: JSON.stringify makes safe JS string + // literals of the agent's src and the message name. Answer and failure ride + // the same message — there is no other road home. + const buildWrapper = (src, msg) => + '(async () => {' + + 'if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) return;' + + 'const G = (globalThis.__ffllmChild ??= {});' + + 'const pid = Services.appinfo.processID;' + + 'if (!G.callSb) {' + + 'const sp = Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal);' + + 'const sb = Cu.Sandbox(sp, { invisibleToDebugger: true, freshCompartment: true,' + + ' sandboxName: "ffllm-call", wantGlobalProperties: ["ChromeUtils", "TextDecoder"] });' + + 'const T = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");' + + 'sb.setTimeout = T.setTimeout; sb.clearTimeout = T.clearTimeout;' + + 'sb.Services = Services; sb.Cc = Cc; sb.Ci = Ci; sb.Cu = Cu;' + + 'G.callSb = sb; }' + + 'let reply;' + + 'try {' + + 'const fn = Cu.evalInSandbox(' + JSON.stringify('(' + src + ')') + + ', G.callSb, null, "ffllm-call/src", 1, false);' + + 'let value = fn();' + + 'if (value && typeof value.then === "function") value = await value;' + + 'reply = { pid, value };' + + '} catch (e) {' + + 'reply = { pid, error: String(e), stack: e && e.stack ? String(e.stack) : null };' + + '}' + + 'try { Services.cpmm.sendAsyncMessage(' + JSON.stringify(msg) + ', reply); }' + + 'catch (e) { Services.cpmm.sendAsyncMessage(' + JSON.stringify(msg) + + ', { pid, error: "return value not structured-cloneable: " + e }); }' + + '})();'; + + const callChild = (target, src, opts = {}) => { + if (target !== 'all' && !Number.isInteger(target)) { + throw new TypeError('callChild is positional: callChild(pid | "all", src, opts)'); + } + if (typeof src !== 'string') { + throw new TypeError('callChild: src must be a string — the source of () => value'); + } + if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError('callChild: opts must be a plain object { timeoutMs }'); + } + for (const k of Object.keys(opts)) { + if (k !== 'timeoutMs') { + throw new TypeError(`callChild: unknown opts key "${k}" — opts is { timeoutMs }`); + } + } + const timeoutMs = opts.timeoutMs ?? 5000; + + // Delivery is per-child-mm, so the parent's own in-process message manager + // is never a target; the wrapper's processType guard stays as a backstop. + const parentPid = Services.appinfo.processID; + const children = []; + for (let i = 0; i < Services.ppmm.childCount; i++) { + const mm = Services.ppmm.getChildAt(i); + let pid = null; + try { pid = mm.osPid; } catch (e) { } + if (pid !== null && pid !== parentPid) children.push({ pid, mm }); + } + const single = target !== 'all'; + const wanted = single ? children.filter((c) => c.pid === target) : children; + if (single && wanted.length === 0) { + throw new Error('callChild: no child process with pid ' + target + + ' — children: [' + children.map((c) => c.pid).join(', ') + ']'); + } + if (wanted.length === 0) return Promise.resolve([]); + + const msg = 'ffllm:call:' + (S._callSeq = (S._callSeq ?? 0) + 1); + const uri = 'data:application/javascript,' + encodeURIComponent(buildWrapper(src, msg)); + + return new Promise((resolve, reject) => { + const pending = new Set(wanted.map((c) => c.pid)); + const results = []; + let timer = null; + const settle = (fn, v) => { + Services.ppmm.removeMessageListener(msg, l); + clearTimeout(timer); + fn(v); + }; + const l = (m) => { + const d = m.data; + if (!pending.has(d.pid)) return; + pending.delete(d.pid); + if (single) { + if (d.error != null) { + settle(reject, new Error(d.error + (d.stack ? '\n' + d.stack : ''))); + } else { + settle(resolve, d.value); + } + return; + } + results.push(d); + if (pending.size === 0) settle(resolve, results); + }; + Services.ppmm.addMessageListener(msg, l); + for (const c of wanted) c.mm.loadProcessScript(uri, false); + timer = setTimeout(() => { + if (single) { + settle(reject, new Error('callChild: timeout after ' + timeoutMs + 'ms (pid ' + target + ')')); + } else { + for (const pid of pending) results.push({ pid, timeout: true }); + settle(resolve, results); + } + }, timeoutMs); + }); + }; + + S.callChild = callChild; +})(); diff --git a/kit/capture.js b/kit/capture.js new file mode 100644 index 00000000..1c5eb94c --- /dev/null +++ b/kit/capture.js @@ -0,0 +1,264 @@ +// ff-llm capture: rings, sinks, and drain — where hook and tap events land and come back. +// +// Both primitives face the same problem once they are attached. Something +// happened, they are holding a live XPCOM object that mutates and is usually +// dead by the time the agent asks, and whatever is not copied right now is +// gone. So neither can be "buffer the events and filter later" — the reduction +// runs at capture, inside the notification or inside the wrapper. What the +// agent gets to keep is decided there and nowhere later. +// +// Everything downstream of that decision is the same for the two, so it lives +// here: the ring or the sink, the counters, drain, and the uninstall. +// tap.js and hook.js differ only in how they attach. +// +// A channel holds the uninstall because the agent cannot. Only serialized +// values cross the eval channel, so a function reference — the observer to +// unregister, the original of a patched method — has to be kept inside Firefox +// or the change is irreversible. Every seam has its own unregister +// (removeObserver, restoring a property, unregisterWindowActor); a channel does +// not care which one, it just calls what it was given. +// +// await drain(id, opts) -> JSON { events, count, ... } taps and hooks alike +// opts { clear: true, limit: 0, redeliver: false } +// Event times are ms since the channel was installed. +// +// Whether `out` is set changes what the buffer is for, and so what drain does. +// `out` names the sink: a string is a file path (JSONL appends), a function is +// called with each batch, and { write, tail?, label? } is the general form — +// write(batch) may return a promise, tail(n) is what drain reads back, label +// is what reports print as `out`. A sink without tail only forwards (a message +// sink: the record lives where the batches land), so drain on it flushes and +// accounts but returns no events, and the report says why. `flushMs` (default +// 5000) bounds how long a partial batch may wait for the next event. +// +// Without it the buffer is the only copy: `max` is a ring, the oldest events +// are dropped once it fills, and drain is the sole exit — FIFO, returning and +// removing the `limit` oldest, or all of them at 0. `clear: false` peeks +// instead. Anything not drained before the ring wraps is gone, and sizing `max` +// is a guess against an event rate nobody knows yet. +// +// A returned batch is not a received batch. The channel cannot tell an awaited +// drain from a fumbled one, and clear-on-return would make that mistake +// destructive — it did once, an unawaited call that consumed three events into +// a promise nobody read. So a ring drain parks what it hands out: the last +// non-empty batch stays replayable until the next non-empty batch overwrites +// it, `redeliver: true` prepends it to the answer (original `t` stamps, so an +// accidental double replay is detectable), and a drain that comes back empty +// while a copy exists says so with `replayable`. Recovery is one batch deep. +// A sink channel needs none of this — the sink's destination is the record. +// +// With it the destination is the record and the only thing held in memory is +// what has not been written yet; `max` is just a write batch size. drain +// flushes that, then reads the last `limit` records back through the sink's +// tail — so what it returns is the tail of the record itself rather than of +// some buffer, and `limit` is not silently capped by however much happened to +// be pending at that instant. The file sink's tail reads a bounded range from +// the end, so draining a channel that has been running all day costs the same +// as draining one installed a second ago. +// +// So with a sink `limit: 0` returns no events at all — flush and accounting +// only, and `clear` means nothing because reading back consumes nothing. +// Pulling an unbounded record back through the channel is the exact cost the +// sink exists to avoid, so asking for a tail should be deliberate. Since +// nothing is dropped the reports carry `flushed` where they carried `dropped`; +// a counter that changes meaning from "you lost data" to "all is well" while +// still reading 0 is worse than no counter. +// +// Writes are dispatched from a sync callback that cannot await them. IOUtils +// keeps same-path writes ordered on its own, so for the file sink the +// per-channel promise chain is not for ordering — it is so drain and remove +// can await every queued write and report a record that is actually complete; +// a custom sink gets its ordering from the same chain. A partial batch would +// otherwise sit in memory until the next event or drain; the flushMs timer +// flushes it, because the crash that loses the unwritten tail is the expected +// failure mode of a tool whose job is patching browser internals. +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + + // Empties the buffer into the sink. Returns the channel's write chain, so + // callers that can wait know when the sink caught up; a failed write is + // recorded on the entry rather than left to reject somewhere unrelated, and + // the chain keeps running so one bad write does not silence the rest. + const flush = (entry) => { + if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; } + if (!entry.out || !entry.pend.length) return entry.pending; + const batch = entry.pend.splice(0, entry.pend.length); + entry.flushed += batch.length; + entry.pending = entry.pending + .then(() => sinkOf(entry).write(batch)) + .catch((e) => { entry.writeError = String(e); }); + return entry.pending; + }; + + // Last `n` records of a JSONL sink. Reads a byte range off the end rather + // than the file, so cost tracks what was asked for and not how long the + // channel has been running; a short file just yields fewer lines than the + // budget. + // + // Deliberately not exposed. Once the events are a file on the agent's own + // disk, reading it is `tail`/`grep`, and an in-Firefox file reader would be + // coreutils duplicated across an RPC boundary. This exists so that drain can + // flush and answer in one call, which the shell cannot do. + const tailOfFile = async (path, n) => { + let size; + try { size = (await IOUtils.stat(path)).size; } catch (e) { return []; } + const start = Math.max(0, size - Math.min(4 << 20, Math.max(64 << 10, n * 8192))); + const bytes = await IOUtils.read(path, { offset: start, maxBytes: size - start }); + let text = new TextDecoder().decode(bytes); + // Starting mid-file almost certainly lands mid-record; that head is not one. + if (start > 0) text = text.slice(text.indexOf('\n') + 1); + return text.split('\n').filter((l) => l).slice(-n) + .map((l) => { try { return JSON.parse(l); } catch (e) { return { unparsed: l }; } }); + }; + + const fileSink = (path) => ({ + label: path, + write: (batch) => IOUtils.writeUTF8(path, + batch.map((e) => JSON.stringify(e)).join('\n') + '\n', { mode: 'appendOrCreate' }), + tail: (n) => tailOfFile(path, n), + }); + + // Built lazily from `out` and cached, so entries opened by an older + // capture.js pick up a sink on their next flush. + const sinkOf = (entry) => + entry.sink ??= typeof entry.out === 'string' ? fileSink(entry.out) + : typeof entry.out === 'function' ? { label: entry.out.name || 'sink', write: entry.out } + : { label: 'sink', ...entry.out }; + + const open = (opts = {}) => ({ + max: opts.max ?? 200, + sample: opts.sample ?? 1, + out: opts.out ?? null, + flushMs: opts.flushMs ?? 5000, + installedAt: Date.now(), + count: 0, dropped: 0, flushed: 0, + buf: [], last: [], pend: [], pending: Promise.resolve(), timer: null, + // Replaced by whatever installed the channel. A channel that never got one + // is not undoable, and reports say so rather than pretending. + uninstall: null, + }); + + // `value` is already reduced — tap ran its extractor, a hook's wrapper chose + // what to pass. `subject` is the live object, kept only long enough for the + // first `sample` events to carry a full inspect of it; that is what closes + // the loop where the agent has to describe a shape it has never seen. + const record = (entry, value, subject, extra) => { + const ev = { t: Date.now() - entry.installedAt, ...extra }; + if (value !== undefined) ev.v = value; + + if (entry.sample > 0 && subject !== undefined) { + entry.sample--; + try { ev.sample = S.inspect ? S.inspect(subject, { qi: true, max: 120 }) : 'no inspect'; } + catch (e) { ev.sample = { error: String(e) }; } + } + + entry.count++; + if (entry.out) { + entry.pend.push(ev); + if (entry.pend.length >= entry.max) flush(entry); + else if (!entry.timer) { + entry.timer = setTimeout(() => { entry.timer = null; flush(entry); }, + entry.flushMs ?? 5000); + } + } else { + entry.buf.push(ev); + while (entry.buf.length > entry.max) { entry.buf.shift(); entry.dropped++; } + } + }; + + const report = (id, e, extra) => { + const r = { id }; + if (e.topic) r.topic = e.topic; + r.count = e.count; + Object.assign(r, extra); + if (e.out) { r.out = sinkOf(e).label; r.flushed = e.flushed; r.unwritten = e.pend.length; } + else { r.dropped = e.dropped; r.remaining = e.buf.length; } + if (e.writeError) r.writeError = e.writeError; + if (e.recordError) r.recordError = e.recordError; + if (e.uninstallError) r.uninstallError = e.uninstallError; + return r; + }; + + // Detach and forget, for either registry. Uninstall runs before the entry + // leaves the registry so a throw is still attributable, and the flush runs + // after: past this point the entry is unreachable, so an unwritten tail would + // be destroyed rather than merely late. + // + // Remove-all unwinds newest-first: an undo restores what its install saved, + // so hooks stacked on one target only come apart in reverse install order — + // insertion order would resurrect the inner wrapper after restoring stock. + const remove = async (reg, id) => { + const removed = []; + for (const k of id === undefined ? Object.keys(reg).reverse() : [id]) { + const e = reg[k]; + if (!e) continue; + if (e.uninstall) { + try { e.uninstall(); } catch (err) { e.uninstallError = String(err); } + } else { + e.uninstallError = 'no undo was registered; restart Firefox to be sure'; + } + delete reg[k]; + await flush(e); + removed.push(report(k, e, {})); + } + return removed; + }; + + const drain = async (id, opts = {}) => { + const o = { clear: true, limit: 0, ...opts }; + const e = (S.taps && S.taps[id]) || (S.hooks && S.hooks[id]); + if (!e) { + return JSON.stringify({ + error: 'no such tap or hook: ' + id, + taps: Object.keys(S.taps || {}), hooks: Object.keys(S.hooks || {}), + }); + } + + let events; + if (e.out) { + await flush(e); + const sink = sinkOf(e); + events = o.limit > 0 && sink.tail ? await sink.tail(o.limit) : []; + } else { + e.last ??= []; // entries opened by an older capture.js + const n = o.limit > 0 ? Math.min(o.limit, e.buf.length) : e.buf.length; + events = o.clear ? e.buf.splice(0, n) : e.buf.slice(0, n); + if (o.redeliver && e.last.length) events = e.last.concat(events); + // An empty batch never overwrites the copy — the retry that comes up + // empty is exactly the caller who still needs it. + if (o.clear && events.length) e.last = events; + } + // Reported before the reset, or `dropped` reads 0 in the one report whose + // job is to say the ring wrapped and the agent is holding an incomplete + // record. It counts losses since the previous drain, not since install. + const r = report(id, e, { returned: events.length }); + if (e.out && o.limit > 0 && !sinkOf(e).tail) { + r.note = 'sink has no tail; the record lives where the batches land'; + } + if (!e.out && !events.length && e.last.length) r.replayable = e.last.length; + if (o.clear && !e.out) e.dropped = 0; + r.events = events; + return JSON.stringify(r); + }; + + // Sink-facing opts are validated here, shared by hook and tap so a wrong + // shape dies synchronously in the caller's frame, not inside a dropped + // promise. + const checkOpts = (opts) => { + const o = opts.out; + if (o !== undefined && o !== null && typeof o !== 'string' && typeof o !== 'function' + && !(typeof o === 'object' && typeof o.write === 'function')) { + throw new TypeError( + 'out must be a file path, a batch function, or { write, tail?, label? }'); + } + if (opts.flushMs !== undefined + && !(typeof opts.flushMs === 'number' && opts.flushMs > 0)) { + throw new TypeError('flushMs must be a positive number of milliseconds'); + } + }; + + // Reached through S at call time, never captured in an installed closure, so + // reloading this file fixes capture for taps and hooks already running. + S._capture = { open, record, flush, report, remove, checkOpts }; + S.drain = drain; +})(); diff --git a/kit/child.js b/kit/child.js new file mode 100644 index 00000000..8b7eacc0 --- /dev/null +++ b/kit/child.js @@ -0,0 +1,159 @@ +// ff-llm primitive: hookChild — carry a hook into content processes. +// +// hook, one process over. hook installs a wrapper in the parent realm; the +// behaviour worth watching often lives in a content process, where the agent's +// channel cannot reach — the eval channel terminates in the parent. hookChild +// carries a hook into every content process (or a named few) and streams what +// it captures back to a parent hook the agent drains as usual. +// +// The seam travels as source. hook takes an install closure (rec) => uninstall +// because agent and kit share a realm; a closure cannot cross a process +// boundary, so hookChild takes the SAME closure as a string and evaluates it in +// each child. Everything else is the hook you know: rec records one event, the +// undo is kept where the agent cannot hold it, drain and unhook are the kit's, +// and the id names one channel. +// +// await hookChild(id, seam, opts) -> JSON (hook()'s report, for the parent side) +// seam source text of (rec) => uninstall, evaluated in each child's +// process-script scope (Services, ChromeUtils, Cc/Ci available). +// rec(value, subject) records one event; `value` must be +// structured-cloneable — it rides a message to the parent. The +// uninstall must be returned synchronously, as with hook. +// opts { targets: 'all' | [pid, ...] default 'all' (covers future procs) +// out, max parent sink and buffer — where the agent drains +// flushMs child send latency, default 250 +// sample } child-side sample is not supported yet; must be 0 +// +// drain(id) / unhook(id) the kit's own. unhook tears down every child +// (broadcast teardown + removeDelayedProcessScript) and removes the parent +// listener. Events carry `pid`; one parent ring holds all processes at once +// — different seams take different ids (channels), processes are pid tags on +// the same channel. +// +// Only capture.js is shipped into the child, pulled from __ffllm._sources (the +// loader retains the shipment). The child mints its own invisibleToDebugger +// sandbox for it, so a Debugger armed in the child later cannot see the capture +// machinery. The parent's own in-process message manager is skipped by a +// processType guard: ppmm delivers a process script to the parent too, and an +// unguarded wrap would double-report from the parent's module copy. +// +// A snapshot, not a subscription: a process that swaps out (a cross-origin +// navigation moves a tab between processes) strands its child hook silently. +// 'all' re-arms new processes — loadProcessScript's delayed flag covers them; a +// targeted [pid] load does not. Teardown loses at most one in-flight child batch +// (newer than the last flush): drain() immediately before unhook() to collect +// it, or keep flushMs small. Validated Nightly 155, +// runs/child-recipe/validation-2026-08-08.md. +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + + // Child flush trigger; flushMs is the latency backstop for a partial batch. + // Small on purpose — the child is the volatile end, evidence should not pool + // there. + const CHILD_BATCH = 20; + + // The process script, as a string: JSON.stringify makes safe JS string + // literals of capture.js (it contains backticks and newlines) and of the + // message names; the seam is the agent's own code, spliced in raw as a + // parenthesized expression. + const buildWrapper = (id, capSrc, seam, msg, ctl, flushMs) => + '(() => {' + + 'if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) return;' + + 'const G = (globalThis.__ffllmChild ??= {});' + + 'if (G[' + JSON.stringify(id) + ']) return;' + + 'const sp = Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal);' + + 'const sb = Cu.Sandbox(sp, { invisibleToDebugger: true, freshCompartment: true,' + + ' sandboxName: "ffllm-child", wantGlobalProperties: ["ChromeUtils", "TextDecoder"] });' + + 'const T = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");' + + 'sb.setTimeout = T.setTimeout; sb.clearTimeout = T.clearTimeout;' + + 'Cu.evalInSandbox(' + JSON.stringify(capSrc) + ', sb, null, "ffllm-child/capture.js", 1, false);' + + 'const S = sb.__ffllm;' + + 'const pid = Services.appinfo.processID;' + + 'const entry = S._capture.open({ sample: 0, max: ' + CHILD_BATCH + ', flushMs: ' + flushMs + + ', out: (batch) => Services.cpmm.sendAsyncMessage(' + JSON.stringify(msg) + + ', { pid, events: batch }) });' + + 'const rec = (value, subject) => { try { S._capture.record(entry, value, subject); }' + + ' catch (e) { entry.recordError = String(e); } };' + + 'let uninstall = null;' + + 'try { uninstall = (' + seam + ')(rec); } catch (e) {' + + ' Services.cpmm.sendAsyncMessage(' + JSON.stringify(msg) + + ', { pid, events: [{ t: 0, installError: String(e) }] }); return; }' + + 'const ctl = () => { Services.cpmm.removeMessageListener(' + JSON.stringify(ctl) + ', ctl);' + + ' try { if (typeof uninstall === "function") uninstall(); } catch (e) {}' + + ' S._capture.flush(entry); delete G[' + JSON.stringify(id) + ']; };' + + 'Services.cpmm.addMessageListener(' + JSON.stringify(ctl) + ', ctl);' + + 'G[' + JSON.stringify(id) + '] = true;' + + '})();'; + + const hookChild = (id, seam, opts = {}) => { + if (typeof id !== 'string') { + throw new TypeError('hookChild is positional: hookChild(id, seam, opts)'); + } + if (typeof seam !== 'string') { + throw new TypeError('hookChild: seam must be a string — the source of (rec) => uninstall'); + } + if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError( + 'hookChild: opts must be a plain object { targets, out, max, flushMs, sample }'); + } + for (const k of Object.keys(opts)) { + if (!['targets', 'out', 'max', 'flushMs', 'sample'].includes(k)) { + throw new TypeError( + `hookChild: unknown opts key "${k}" — opts is { targets, out, max, flushMs, sample }`); + } + } + const targets = opts.targets ?? 'all'; + if (targets !== 'all' + && !(Array.isArray(targets) && targets.every((p) => typeof p === 'number'))) { + throw new TypeError('hookChild: targets must be "all" or an array of pids'); + } + if (opts.sample) { + throw new TypeError( + 'hookChild: child-side sample is not supported yet — shape the value inside your seam'); + } + const src = (S._sources || []).find((f) => f.name === 'capture.js'); + if (!src) { + throw new Error( + 'hookChild: capture.js not in __ffllm._sources — reload the kit (ensure_privileged_kit)'); + } + + const msg = 'ffllm:child:' + id; + const ctl = 'ffllm:child-teardown:' + id; + const flushMs = opts.flushMs ?? 250; + const delayed = targets === 'all'; + const uri = 'data:application/javascript,' + + encodeURIComponent(buildWrapper(id, src.source, seam, msg, ctl, flushMs)); + + return (async () => { + // Replacing an id must tear the prior arming out of the children first — + // hook alone would only swap the parent listener and leave the old process + // script running. unhook(id) runs this file's uninstall, which broadcasts + // teardown. + if (S.hooks && id in S.hooks) await S.unhook(id); + + return S.hook(id, (rec) => { + const l = (m) => { + for (const ev of m.data.events) rec(Object.assign({ pid: m.data.pid }, ev)); + }; + Services.ppmm.addMessageListener(msg, l); + if (delayed) { + Services.ppmm.loadProcessScript(uri, true); + } else { + for (let i = 0; i < Services.ppmm.childCount; i++) { + const mm = Services.ppmm.getChildAt(i); + let pid = null; + try { pid = mm.osPid; } catch (e) { } + if (targets.includes(pid)) mm.loadProcessScript(uri, false); + } + } + return () => { + Services.ppmm.broadcastAsyncMessage(ctl); + if (delayed) Services.ppmm.removeDelayedProcessScript(uri); + Services.ppmm.removeMessageListener(msg, l); + }; + }, { out: opts.out ?? null, max: opts.max ?? 500, sample: 0 }); + })(); + }; + + S.hookChild = hookChild; +})(); diff --git a/kit/hook.js b/kit/hook.js new file mode 100644 index 00000000..98ad631f --- /dev/null +++ b/kit/hook.js @@ -0,0 +1,165 @@ +// ff-llm primitive: hook — stand in any Firefox function's path and capture its calls. +// +// The active counterpart of tap. A tap watches a seam; a hook stands in it — +// the wrapper sees the arguments and may change them, change the return value, +// or not call through at all. +// +// The primitive knows no seams and should not. A catalogue of interposition +// points is a table that rots every Firefox release, and introspect exists so +// the agent can re-derive one against the running browser instead. So the agent +// brings the seam, as an install closure that attaches however it likes and +// returns the closure that detaches. What hook owns is what outlives the call: +// the registry, the undo the agent cannot hold, and capture.js. +// +// await hook(id, install, opts) -> JSON { id, installed, undo, replaced, +// prior? } prior is the replaced hook's final report +// install (rec) => uninstall +// rec(value, subject) records one event and never throws; subject, +// if given, is what `sample` inspects. +// Return nothing and the hook is not undoable — reports say so. +// opts { sample: 1, max: 200, out: null, flushMs: 5000 } see capture.js +// +// await unhook(id) -> JSON unhook() with no id removes every hook, +// newest-first, so a stack on one target comes apart cleanly +// +// Wrong-shaped arguments (non-string id, non-function install, unknown opts +// keys) throw synchronously and kill the payload; a failing installer still +// returns its { installed: false } report. +// +// drain(id) is capture.js's and reads hooks and taps alike. +// +// Seams to reach for before patching anything, none of them special-cased here: +// nsITraceableChannel.setNewListener hands back the listener it replaces, +// http-on-modify-request is an observer topic Necko fires so listeners can +// alter the request, ChromeUtils.registerWindowActor has its own unregister. +// Replacing a JS property is the fallback for the frontend, where no extension +// point was ever designed — which is most of it. +// +// One seam is a backstop rather than a target. A well-formed call whose async +// phase fails after the sync frame has returned travels only in a promise +// nobody holds — silently, since agents cannot read the Browser Console. +// PromiseDebugging, a bare global in chrome realms, reports exactly those: +// +// hook('rejections', (rec) => { +// const obs = { +// onLeftUncaught(p) { +// let reason = '[unreadable]'; +// try { reason = String(PromiseDebugging.getState(p).reason); } +// catch (e) {} +// rec({ kind: 'left', id: PromiseDebugging.getPromiseID(p), reason }, p); +// return false; +// }, +// onConsumed(p) { +// rec({ kind: 'consumed', id: PromiseDebugging.getPromiseID(p) }); +// }, +// }; +// PromiseDebugging.addUncaughtRejectionObserver(obs); +// return () => PromiseDebugging.removeUncaughtRejectionObserver(obs); +// }, { max: 200 }) +// +// A handler attached in the same task as the rejection reports nothing — the +// benign pattern filters itself. A later handler reports a `left`/`consumed` +// pair with one id; only an unpaired `left` is a leak. The reason is readable +// only inside onLeftUncaught, and delivery lands a couple of event-loop turns +// after the fact — the next tool call is always late enough to see it. +// Validated Nightly 155. +// +// Two caveats stay the agent's to track. An undo restores what its install +// saved, so hooks stacked on one target must come off newest-first — bare +// unhook() does that on its own, but unhook(id) on a buried hook clobbers +// whoever wrapped after it. And any reference taken before the patch keeps +// calling the old function. Restarting Firefox is the backstop. +// +// Record every call, pass it through unchanged — a hook doing tap's job: +// +// hook('loadURI', (rec) => { +// const orig = gBrowser.loadURI; +// gBrowser.loadURI = function (...args) { +// rec(String(args[0]), args[0]); +// return orig.apply(this, args); +// }; +// return () => { gBrowser.loadURI = orig; }; +// }) +// +// install is a closure rather than a target and a member name because some +// seams can only be taken from inside a notification: setNewListener works +// during http-on-examine-response and nowhere else, so that hook's install +// registers an observer and splices from the callback. Composing with tap +// needs no support here, only the freedom to run arbitrary attach code. +// +// Installed hooks are their own inventory — await describe('__ffllm.hooks') +// lists them, describe('__ffllm.hooks.') shows one, and the undo's full +// source is describe('__ffllm.hooks..uninstall') away. +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + // Null prototype: ids like "toString" must not collide with inherited keys. + S.hooks = Object.assign(Object.create(null), S.hooks); + + // Only the body is async — replacing a hook awaits the outgoing one's last + // flush. The function itself must not be: a shape error has to throw + // synchronously in the caller's frame, where it kills the whole payload, + // not seal itself into a promise a bare call is free to drop. + const hook = (id, install, opts = {}) => { + if (typeof id !== 'string') { + throw new TypeError('hook is positional: hook(id, install, opts)' + + (id !== null && typeof id === 'object' + ? ' — did you mean hook(o.id, o.install, o.opts)?' : '')); + } + if (typeof install !== 'function') { + throw new TypeError('hook: install must be a function (rec) => uninstall'); + } + if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError('hook: opts must be a plain object { sample, max, out, flushMs }'); + } + for (const k of Object.keys(opts)) { + if (!['sample', 'max', 'out', 'flushMs'].includes(k)) { + throw new TypeError( + `hook: unknown opts key "${k}" — opts is { sample, max, out, flushMs }`); + } + } + S._capture.checkOpts(opts); + return (async () => { + if (S.taps && id in S.taps) { + return JSON.stringify({ id, installed: false, error: 'id already names a tap' }); + } + let prior; + if (id in S.hooks) [prior] = await S._capture.remove(S.hooks, id); + + const entry = S._capture.open(opts); + // Reached through S at call time so reloading capture.js fixes live hooks, + // and swallowing here because the wrapper is on someone's critical path: + // a throw out of rec would surface as a failure in whatever was hooked. + const rec = (value, subject) => { + try { S._capture.record(entry, value, subject); } + catch (e) { entry.recordError = String(e); } + }; + + // Attaching is the agent's code and may fail; registering a hook that never + // installed would leave an undo that undoes nothing. + let uninstall; + try { uninstall = install(rec); } + catch (e) { + const r = { id, installed: false, error: String(e) }; + if (prior) r.prior = prior; + return JSON.stringify(r); + } + + entry.uninstall = typeof uninstall === 'function' ? uninstall : null; + S.hooks[id] = entry; + const r = { id, installed: true, undo: !!entry.uninstall, replaced: !!prior }; + if (prior) r.prior = prior; + return JSON.stringify(r); + })(); + }; + + const unhook = (id) => { + if (id !== undefined && typeof id !== 'string') { + throw new TypeError('unhook: id must be a string, or omitted to remove every hook'); + } + return (async () => + JSON.stringify({ removed: await S._capture.remove(S.hooks, id) }))(); + }; + + S.hook = hook; + S.unhook = unhook; +})(); diff --git a/kit/hookscript.js b/kit/hookscript.js new file mode 100644 index 00000000..9aee382a --- /dev/null +++ b/kit/hookscript.js @@ -0,0 +1,233 @@ +// ff-llm primitive: hookScript — break on a line of Firefox's own code and record each hit. +// +// A hook whose seam is a source location instead of a property: any position +// the engine can stop at, in any compiled chrome script, addressed by url plus +// displayName and/or line. Reaches what wrapping cannot — private methods +// (#name), closures, and a function's locals mid-flight — with optional +// write-back. Wrapping by name stays the right tool for everything a property +// can reach: it is orders of magnitude cheaper (arming a breakpoint discards +// the script's JIT code; every hit costs ~35-60us) and a name survives +// releases where a line number drifts. +// +// hookScript(id, where) -> JSON { id, installed, undo, armed: {url, displayName, line, column} } +// (async, like hook itself — await it) +// where.url script url, e.g. 'chrome://browser/content/tabbrowser/tabbrowser.js' +// where.displayName the function's displayName or a substring of it — private +// methods appear literally, e.g. '#insertTabAtIndex' +// where.line arm the first stoppable site on this line; omit to arm +// the first site of the matched function +// where.global the debuggee — any object from the target realm +// (normalized via Cu.getGlobalForObject); a script +// compiles once per realm, so this picks which copy is +// armed. Default: the most recent browser window. +// where.self only record when frame.this is exactly this object — the +// per-instance filter (a line is shared by every instance +// of a class; skipped instances still pay the trap, so +// prefer a wrapper when the method is name-reachable) +// where.set { name: value } — on each hit, write these frame locals +// via env.find(name).setVariable before the frame resumes; +// undefined/null/boolean/number/string only +// +// Wrong-shaped calls (non-string id, unknown where keys, missing url) throw +// synchronously and kill the payload; a failing arm still returns its +// { installed: false } report. +// +// Each hit records { callee, args, locals, wrote?, stack } through the normal +// capture path: drain(id) reads it, unhook(id) disarms, and +// Cu.getGlobalForObject(__ffllm.hook)['__ffllm_ctl_' + id].stats() reports +// hits/skips/errors between calls (the controller lives on the kit sandbox +// global, not the window). Locals are read through env.find, which walks the +// scope chain — +// Environment.getVariable alone sees only one environment and misses names +// bound in enclosing blocks. +// +// Hazards, all measured: the function-end position is a stoppable site where +// an implicit return would run — a function that always leaves through an +// explicit return never reaches it, so a breakpoint armed there is valid and +// silently dead; stats() hits staying 0 is the symptom. A const local reads +// [unreadable] before its declaration runs (temporal dead zone) — parameters +// read anywhere; arm a later site to see locals. Re-entry by a hook's own rec +// is guarded (dropped, counted as selfSkips), but one hook's rec crossing +// another hook's armed site is not: never break on the kit's own capture path. +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + + // The async body — entered only after the façade below has validated the + // shape. `target` is the debuggee global, already normalized. + const armScriptHook = async (id, where, target) => { + let armed = null; + const report = await S.hook(id, (rec) => { + const sp = Cc['@mozilla.org/systemprincipal;1'].createInstance(Ci.nsIPrincipal); + const sb = Cu.Sandbox(sp, { invisibleToDebugger: true, sandboxName: 'ffllm-hookscript' }); + const { addDebuggerToGlobal } = + ChromeUtils.importESModule('resource://gre/modules/jsdebugger.sys.mjs'); + addDebuggerToGlobal(sb); + + // Compiled inside the sandbox: a closure written here would be debuggee + // code, and the debugger's own code has to stay out of view. + const makeController = Cu.evalInSandbox('(' + String(function (rec, opt) { + const dbg = new Debugger(); + const stats = { hits: 0, selfSkips: 0, instanceSkips: 0, errors: 0, lastError: null }; + let recDepth = 0; + let selfDO = null; + + const dv = (v, depth) => { + const t = typeof v; + if (v === null || t === 'number' || t === 'boolean') return v; + if (t === 'undefined') return '(undefined)'; + if (t === 'string') return v.length > 200 ? v.slice(0, 200) + '…' : v; + if (t !== 'object') { try { return String(v); } catch (e) { return '[' + t + ']'; } } + try { + if (typeof v.getOwnPropertyNames !== 'function') + return v.optimizedOut ? '(optimized out)' : '[non-debuggee object]'; + if (v.callable) return 'function ' + (v.name || '(anon)'); + const out = { class: v.class }; + if (depth > 0) for (const n of v.getOwnPropertyNames().slice(0, 10)) { + try { + const d = v.getOwnPropertyDescriptor(n); + out[n] = d && 'value' in d ? dv(d.value, depth - 1) : '[accessor]'; + } catch (e) { out[n] = '[unreadable]'; } + } + return out; + } catch (e) { return '[unreadable: ' + e + ']'; } + }; + + const snap = (frame) => { + const ev = { callee: (frame.callee && (frame.callee.displayName || frame.callee.name)) || frame.type }; + try { ev.args = (frame.arguments || []).slice(0, 8).map((a) => dv(a, 1)); } catch (e) { } + try { + let env = frame.environment; + ev.locals = {}; + let count = 0; + for (let e2 = env, hops = 0; e2 && hops < 4 && count < 30; e2 = e2.parent, hops++) { + for (const n of e2.names()) { + if (n in ev.locals || count >= 30) continue; + try { ev.locals[n] = dv(e2.getVariable(n), 1); count++; } + catch (e) { ev.locals[n] = '[unreadable]'; } + } + } + } catch (e) { } + ev.stack = []; + for (let f = frame, i = 0; f && i < 15; f = f.older, i++) { + try { + let line = '?'; + try { line = f.script.getOffsetMetadata(f.offset).lineNumber; } catch (e) { } + ev.stack.push(((f.callee && (f.callee.displayName || f.callee.name)) || f.type) + + ' @ ' + (f.script ? f.script.url : '?') + ':' + line); + } catch (e) { ev.stack.push('[unreadable]'); } + } + return ev; + }; + + return { + stats: () => JSON.parse(JSON.stringify(stats)), + arm(global, self) { + const gdo = dbg.addDebuggee(global); + if (self) selfDO = gdo.makeDebuggeeValue(self); + let scripts = dbg.findScripts({ url: opt.url }); + if (opt.displayName) { + scripts = scripts.filter((s) => + String(s.displayName || '').includes(opt.displayName)); + scripts.sort((a, b) => + (a.displayName === opt.displayName ? 0 : 1) - + (b.displayName === opt.displayName ? 0 : 1)); + } + if (!scripts.length) throw new Error('no script matches url/displayName'); + let script = null, site = null; + for (const s of scripts) { + const cands = s.getPossibleBreakpoints() + .filter((b) => opt.line == null || b.lineNumber === opt.line); + if (cands.length) { script = s; site = cands[0]; break; } + } + if (!site) throw new Error('no stoppable site there; matching scripts: ' + scripts.length); + script.setBreakpoint(site.offset, { + hit: (frame) => { + try { + if (recDepth) { stats.selfSkips++; return; } + if (selfDO && frame.this !== selfDO) { stats.instanceSkips++; return; } + stats.hits++; + const ev = snap(frame); + if (opt.set) { + ev.wrote = {}; + for (const n of Object.keys(opt.set)) { + try { + const e2 = frame.environment.find(n); + if (e2) { e2.setVariable(n, opt.set[n]); ev.wrote[n] = true; } + else ev.wrote[n] = 'no binding'; + } catch (e) { ev.wrote[n] = String(e); } + } + } + // Deferred out of the handler: rec's path is debuggee code, + // and a hit during it must not nest (see breakpoint-hook.md). + Promise.resolve().then(() => { + recDepth++; + try { rec(ev); } catch (e) { } finally { recDepth--; } + }); + } catch (e) { stats.errors++; stats.lastError = String(e); } + }, + }); + return { url: script.url, displayName: script.displayName || null, + line: site.lineNumber, column: site.columnNumber }; + }, + uninstall() { dbg.removeAllDebuggees(); }, + }; + }) + ')', sb); + + const ctl = makeController(rec, { + url: where.url, + displayName: where.displayName ?? null, + line: where.line ?? null, + set: where.set ?? null, + }); + // Arm before publishing: a failed arm must not leave __ffllm_ctl_ + // behind with no registered hook to clean it up. + armed = ctl.arm(target, where.self ?? null); + globalThis['__ffllm_ctl_' + id] = ctl; + return () => { ctl.uninstall(); delete globalThis['__ffllm_ctl_' + id]; }; + }); + + const r = JSON.parse(report); + r.armed = armed; + return JSON.stringify(r); + }; + + // Only the body is async — a shape error has to throw synchronously in the + // caller's frame, where it kills the whole payload, not seal itself into a + // promise a bare call is free to drop (the hook.js / tap.js façade). + S.hookScript = (id, where = {}) => { + if (typeof id !== 'string') { + throw new TypeError('hookScript is positional: hookScript(id, where)'); + } + if (where === null || typeof where !== 'object' || Array.isArray(where)) { + throw new TypeError( + 'hookScript: where must be a plain object { url, displayName, line, self, set, global }'); + } + for (const k of Object.keys(where)) { + if (!['url', 'displayName', 'line', 'self', 'set', 'global'].includes(k)) { + throw new TypeError(`hookScript: unknown where key "${k}" — where is ` + + '{ url, displayName, line, self, set, global }'); + } + } + if (!where.url) throw new TypeError('hookScript: where.url is required'); + for (const [n, v] of Object.entries(where.set || {})) { + const t = typeof v; + if (!(v === null || t === 'undefined' || t === 'boolean' || t === 'number' || t === 'string')) + throw new TypeError('hookScript: set.' + n + ' must be a primitive'); + } + let target; + if (where.global !== undefined) { + if (where.global === null || + (typeof where.global !== 'object' && typeof where.global !== 'function')) { + throw new TypeError( + 'hookScript: where.global must be an object from the target realm'); + } + target = Cu.getGlobalForObject(where.global); + } else { + target = Services.wm.getMostRecentWindow('navigator:browser'); + if (!target) { + throw new Error('hookScript: no browser window open — pass where.global'); + } + } + return armScriptHook(id, where, target); + }; +})(); diff --git a/kit/introspect.js b/kit/introspect.js new file mode 100644 index 00000000..14677115 --- /dev/null +++ b/kit/introspect.js @@ -0,0 +1,167 @@ +// ff-llm primitive: introspect — describe any live object, module, or path inside Firefox. +// +// Runs in Firefox's parent process, in the kit's own sandbox — an +// invisibleToDebugger system-principal sandbox no Debugger can enumerate, so the +// kit hooks the chrome globals without seeing itself. The server's +// ensure_privileged_kit tool loads every kit file into it and anchors the result +// at `Cu.getGlobalForObject(Services).__ffllm`, which roots it against GC and +// lets any chrome realm reach it; the reuse guard keeps one kit per process, so +// re-running the tool reloads the files in place — the whole edit-reload cycle. +// +// Every entry point that answers through the channel is async — await every +// `__ffllm` call, always. The exceptions are in-process helpers that must stay +// callable from inside a sync extractor: inspect and preview. +// +// await describe(target, opts) -> JSON string +// target a value, or a path string. "resource://…" (or any "://") is imported +// as a module; a dotted path like "gBrowser.selectedTab" resolves first +// against the kit realm (Services, __ffllm), then the most recent +// browser window (gBrowser, window, document). +// DevTools modules are not ESM — reach them through their loader: +// importESModule("resource://devtools/shared/loader/Loader.sys.mjs") +// .require("devtools/client/framework/devtools").gDevTools +// (devtools-browser exports gDevToolsBrowser, the wrong module; +// validated Nightly 155). +// opts { proto: true, walk the prototype chain, not just own props +// values: true, preview data props and invoke getters +// deep: false, include Object.prototype boilerplate +// qi: false, QI-test against every Ci interface (~1200 probes) +// root: obj, for a string path, the object to resolve it from — +// overrides the kit-realm-then-window default +// max: 200 } member cap +// A function target additionally carries `src`, its full source; sig() in +// member listings stops at the signature. +// +// inspect(target, opts) -> the same result as a live object, not serialized, for +// callers inside the process. A tap's extractor is an inspect call: it has the +// live subject for the length of one notification and nothing after. +// +// await write(path, data) -> JSON string for results too big to spend context on +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + const Ci = globalThis.Ci || Components.interfaces; + + const CLASS = (v) => Object.prototype.toString.call(v).slice(8, -1); + const kind = (v) => (v === null ? 'null' : typeof v === 'object' ? CLASS(v) : typeof v); + + // Real param names when the function is JS; "[native code]" marks the C++ edge. + const sig = (fn) => { + let src = ''; + try { src = Function.prototype.toString.call(fn); } catch (e) { } + const brace = src.indexOf('{'); + const head = src.slice(0, brace > 0 ? brace + 1 : 200).replace(/\s+/g, ' ').trim(); + if (!head) return `${fn.name || '?'}/${fn.length}`; + return head.length > 200 ? head.slice(0, 200) + '…' : head; + }; + + const preview = (v) => { + const k = kind(v); + switch (k) { + case 'undefined': case 'null': return k; + case 'number': case 'boolean': case 'bigint': return String(v); + case 'symbol': return v.toString(); + case 'string': return v.length > 100 ? JSON.stringify(v.slice(0, 100)) + '…' : JSON.stringify(v); + case 'function': return sig(v); + case 'Array': return `Array(${v.length})`; + case 'Map': case 'Set': return `${k}(${v.size})`; + } + try { return `[${k} ${v.constructor && v.constructor.name || ''}]`.replace(' ]', ']'); } + catch (e) { return `[${k}]`; } + }; + + const inspect = (target, opts = {}) => { + const o = { proto: true, values: true, deep: false, qi: false, max: 200, ...opts }; + let obj = target, path = null; + + if (typeof target === 'string') { + path = target; + if (target.includes('://')) { + obj = ChromeUtils.importESModule(target); + } else { + const segs = target.split('.'); + // A string path is realm-relative, and the kit runs in its own sandbox — + // nobody's namespace. Resolve the first segment against the kit realm + // (where __ffllm and Services live), then the most recent browser window + // (where gBrowser, window, document live). opts.root forces a start + // object you already hold. + const roots = o.root !== undefined + ? [o.root] + : [globalThis, Services.wm.getMostRecentWindow('navigator:browser')].filter((r) => r != null); + const root = roots.find((r) => r[segs[0]] !== undefined) ?? roots[0]; + obj = root == null ? undefined : segs.reduce((x, k) => (x == null ? x : x[k]), root); + } + } + + const res = { path, kind: kind(obj), members: [], truncated: false }; + try { res.ctor = obj != null && obj.constructor && obj.constructor.name; } catch (e) { } + if (obj == null || (typeof obj !== 'object' && typeof obj !== 'function')) { + res.value = preview(obj); + return res; + } + + if (typeof obj === 'function') { + let src = ''; + try { src = Function.prototype.toString.call(obj); } catch (e) { } + if (src) res.src = src.length > 2000 ? src.slice(0, 2000) + '…' : src; + } + + if (o.qi && typeof obj.QueryInterface === 'function') { + res.interfaces = []; + for (const name of Object.keys(Ci)) { + try { obj.QueryInterface(Ci[name]); res.interfaces.push(name); } catch (e) { } + } + } + + const seen = new Set(); + let cur = obj, level = 0; + while (cur != null && level < (o.proto ? 12 : 1) && !res.truncated) { + // Object.prototype is noise the agent already knows; it never carries signal. + if (!o.deep && cur === Object.prototype) break; + let from = 'own'; + if (level > 0) { + let n = ''; + try { n = (cur.constructor && cur.constructor.name) || CLASS(cur); } catch (e) { n = CLASS(cur); } + from = `proto${level}:${n}`; + } + for (const key of Reflect.ownKeys(cur)) { + const name = String(key); + if (seen.has(name)) continue; + seen.add(name); + if (res.members.length >= o.max) { res.truncated = true; break; } + const e = { name, from }; + let d; + try { d = Object.getOwnPropertyDescriptor(cur, key); } catch (err) { e.kind = 'opaque'; res.members.push(e); continue; } + if (d.get) { + e.kind = 'getter'; + if (o.values) { try { e.value = preview(d.get.call(obj)); } catch (err) { e.value = 'throws: ' + err; } } + } else if (d.set) { + e.kind = 'setter'; + } else if (typeof d.value === 'function') { + e.kind = 'method'; e.arity = d.value.length; e.sig = sig(d.value); + } else { + // type is recorded even when values is off, so "descend into this one" + // stays answerable without previewing every property. + e.kind = 'data'; e.type = kind(d.value); + if (o.values) e.value = preview(d.value); + } + res.members.push(e); + } + try { cur = Object.getPrototypeOf(cur); } catch (err) { break; } + level++; + } + return res; + }; + + // Kept separate from describe rather than a describe({out}) option: it composes + // with drains and future primitives, and describe stays synchronous. + const write = async (path, data) => { + const s = typeof data === 'string' ? data : JSON.stringify(data); + await IOUtils.writeUTF8(path, s); + return JSON.stringify({ path, bytes: s.length }); + }; + + S.inspect = inspect; + S.describe = async (target, opts) => JSON.stringify(inspect(target, opts)); + S.preview = preview; + S.write = write; +})(); diff --git a/kit/loader.js b/kit/loader.js new file mode 100644 index 00000000..c2128891 --- /dev/null +++ b/kit/loader.js @@ -0,0 +1,37 @@ +// ff-llm loader: how the kit enters Firefox — creates the sandbox and evaluates the kit sources. +// +// Not a primitive: this whole file is the functionDeclaration of the BiDi +// script.callFunction that ensure_privileged_kit sends, with the other kit +// files' sources as its JSON argument. It ships beside them so the code that +// creates the sandbox is as readable as the code that runs inside it. +// +// Evaluates every kit source, shipped in as JSON, into one invisibleToDebugger +// system-principal sandbox anchored on the shared system global so it outlives +// the window that loaded it. The reuse branch evaluates into the existing +// sandbox, keeping one kit per process; each file is an IIFE, so re-evaluating +// replaces its exports in place and leaves live hooks and buffers alone. +// The sources arrive over the wire and have no url, so filename restrictions are +// off: the alternative is claiming a resource:// uri that resolves nowhere, or +// omitting the name and attributing every kit stack frame to browser.xhtml. +(json) => { + const files = JSON.parse(json); + const anchor = Cu.getGlobalForObject(Services); + const reused = !!anchor.__ffllm; + const sb = reused + ? Cu.getGlobalForObject(anchor.__ffllm.hook) + : Cu.Sandbox(Cc['@mozilla.org/systemprincipal;1'].createInstance(Ci.nsIPrincipal), { + invisibleToDebugger: true, freshCompartment: true, sandboxName: 'ffllm-kit', + wantGlobalProperties: ['ChromeUtils', 'IOUtils', 'TextDecoder'] }); + if (!reused) { + const T = ChromeUtils.importESModule('resource://gre/modules/Timer.sys.mjs'); + sb.setTimeout = T.setTimeout; sb.clearTimeout = T.clearTimeout; + } + for (const f of files) + Cu.evalInSandbox(f.source, sb, null, 'ffllm/' + f.name, 1, false); + // Retained so the parent can re-ship the kit into child processes without + // the sources ever crossing the agent channel again. + sb.__ffllm._sources = files; + anchor.__ffllm = sb.__ffllm; + return JSON.stringify({ reused, loaded: files.map(f => f.name), + api: Object.keys(sb.__ffllm) }); +} diff --git a/kit/recipe-rejection-observer.md b/kit/recipe-rejection-observer.md new file mode 100644 index 00000000..5868ca83 --- /dev/null +++ b/kit/recipe-rejection-observer.md @@ -0,0 +1,103 @@ +# Rejection observer — every uncaught rejection in the privileged realm, as drainable evidence + +A standing `hook` that turns every promise rejection left uncaught in the +privileged realm into a drainable event. Zero new machinery: the seam is +`PromiseDebugging`; ring, drain and undo are the kit's. Validated against +Nightly 155.0a1 (buildID 20260731085738) on 2026-08-03. + +The kit's wrong-shape guards throw synchronously, so a malformed call kills +its payload; what they cannot catch is a well-formed call whose async phase +fails after the sync frame has returned. That failure travels only in the +returned promise, and a promise nobody holds drops it silently — and this +environment has no other backstop, since agents cannot read the Browser +Console. This observer is the backstop: installed once, every rejection +nobody handled becomes an event in an ordinary capture ring. + +## What it stands on + +`PromiseDebugging` is a bare global in the browser.xhtml realm — chrome-only, +nothing to import. Members: `getState`, `getPromiseID`, `getAllocationStack`, +`getRejectionStack`, `getFullfillmentStack` (the triple-l is the real IDL +spelling), `addUncaughtRejectionObserver`, `removeUncaughtRejectionObserver`. + +`addUncaughtRejectionObserver(obs)` reports through two callbacks: + +- `obs.onLeftUncaught(p)` — `p` was rejected and finished its task with no + handler. Delivery rides a dispatched runnable and lands about two + event-loop turns after the fact (measured sub-ms); a following tool call + is always late enough to see it. There is no JS-callable flush — + `flushUncaughtRejections` is C++-only and asserts off-main-thread + (`dom/promise/PromiseDebugging.cpp:179`). +- `obs.onConsumed(p)` — a previously reported promise later got a handler. + +Three behaviors the recipe leans on, all confirmed live: + +- A `.catch` attached in the same task as the rejection produces no events + at all — the common benign pattern is auto-invisible. +- A handler attached in a later task produces an ordered pair, + `onLeftUncaught` then `onConsumed`, with the same `getPromiseID` (a string + like `PromiseDebugging.0.593`). Only an unpaired `left` is a real leak; + correlate by id across drains, since a drain between the two clears the + `left` out first. +- `onConsumed` carries no reason. The reason must be read in + `onLeftUncaught` via `getState(p).reason`, while the promise is at hand — + that is what makes it survive into the buffer. + +Returning `true` from `onLeftUncaught` is documented in the IDL to suppress +the console report, but is dead on the main thread: the console message +comes from an independent path (`CycleCollectedJSContext:: +AfterProcessMicrotasks`, `xpcom/base/CycleCollectedJSContext.cpp:539`). +Return `false` and expect the console line regardless. + +## Install + +An ordinary hook; ring, drain and file sink are capture.js's: + +```js +await __ffllm.hook('rejections', (rec) => { + const obs = { + onLeftUncaught(p) { + let reason = ''; + try { reason = String(PromiseDebugging.getState(p).reason); } + catch (e) { reason = '[unreadable]'; } + rec({ kind: 'left', id: PromiseDebugging.getPromiseID(p), reason }, p); + return false; + }, + onConsumed(p) { + rec({ kind: 'consumed', id: PromiseDebugging.getPromiseID(p) }); + }, + }; + PromiseDebugging.addUncaughtRejectionObserver(obs); + return () => PromiseDebugging.removeUncaughtRejectionObserver(obs); +}, { max: 200 }); +``` + +## Use + +```js +// one call leaks: +Promise.reject(new Error('ffllm-leak-A')); return 'leaked'; +// any later call drains: +return await __ffllm.drain('rejections'); +``` + +The drained event, verbatim from the validation run: + +```json +{ "kind": "left", "id": "PromiseDebugging.0.593", "reason": "Error: ffllm-leak-A" } +``` + +The first event also carries a full inspect of the promise (`sample: 1` +default) — about 700 bytes, once. `unhook('rejections')` detaches cleanly: +after it, a deliberately leaked rejection produced a console error and no +event. + +## Noise + +Idle headless bench, ~40 s window: zero unrelated rejections. An earlier +probe on the same build, with activity in the session: about 3 unrelated +rejections per minute. Filter by reason string and treat only unpaired +`left` events as leaks. + +Incidental from the same probe: `Cu.now` is undefined in this realm — +`ChromeUtils.now()` is the clock. diff --git a/kit/tap.js b/kit/tap.js new file mode 100644 index 00000000..f63206b8 --- /dev/null +++ b/kit/tap.js @@ -0,0 +1,143 @@ +// ff-llm primitive: tap — watch an observer topic and keep what the notifications carry. +// +// Passive observation of an observer-service topic. Installing is addObserver +// and removing is removeObserver; everything between them — reducing at +// capture, the buffer or the sink, drain — is capture.js. +// +// Because the reduction runs inside the notification, the agent has to know the +// subject's shape before it has ever seen one. That is what `sample` is for: +// the first N notifications also carry a full introspect of the live subject, QI +// list included. Read it, write a real extractor, re-tap. Retapping the same id +// replaces the old tap in place; the outgoing tap's final report — including +// how many undrained events it took with it — returns as `prior`. +// +// Only observer topics for now. Event listeners and nsIWebProgressListener are +// the other passive seams and are not wired up. A topic that exists to be +// intervened in rather than watched — http-on-modify-request — is a hook even +// though addObserver installs it; the mechanism does not decide which primitive +// it is, the wrapper's licence to change the outcome does. +// +// await tap(topic, opts) -> JSON { id, topic, replaced, prior? } +// opts { extract: (subject, data, topic) => any default: ctor + toString +// sample, max, out, flushMs see capture.js +// id } defaults to the topic +// +// await untap(id) -> JSON untap() with no id removes every tap +// +// Wrong-shaped arguments (non-string topic, unknown opts keys) throw +// synchronously and kill the payload. +// +// Installed taps are their own inventory — await describe('__ffllm.taps') +// lists them, describe('__ffllm.taps.') shows one, and the extractor's +// full source is describe('__ffllm.taps..extract') away. There is no +// separate ledger to drift out of sync with the process. +(() => { + const S = (globalThis.__ffllm ??= { installedAt: Date.now() }); + // Null prototype: ids like "toString" must not collide with inherited keys. + S.taps = Object.assign(Object.create(null), S.taps); + + // Reloading only reassigns what the new version installs, so anything an + // older one left behind stays callable until deleted here or the browser + // restarts. Retiring a name means saying so. + // + // obs/buf/max/installed the hand-written http-on-examine-response observer + // this primitive generalizes; it had no removal path + // tail briefly public, now internal — capture.js + // _record moved to _capture.record, along with the rest of + // the machinery hook turned out to need too + if (S.obs) { + try { Services.obs.removeObserver(S.obs, 'http-on-examine-response'); } catch (e) { } + delete S.obs; delete S.buf; delete S.max; delete S.installed; + } + delete S.tail; + delete S._record; + + const summary = (v) => { + if (v === null || (typeof v !== 'object' && typeof v !== 'function')) return v; + const out = {}; + try { out.ctor = (v.constructor && v.constructor.name) || null; } catch (e) { } + try { + const s = String(v); + if (s !== '[object Object]') out.str = s.length > 200 ? s.slice(0, 200) + '…' : s; + } catch (e) { } + return out; + }; + + // Only the body is async — replacing a tap awaits the outgoing one's last + // flush. The function itself must not be: a shape error has to throw + // synchronously in the caller's frame, where it kills the whole payload, + // not seal itself into a promise a bare call is free to drop. + const tap = (topic, opts = {}) => { + if (typeof topic !== 'string') { + throw new TypeError('tap is positional: tap(topic, opts)' + + (topic !== null && typeof topic === 'object' + ? ' — pass the topic string first, everything else in opts' : '')); + } + if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) { + throw new TypeError( + 'tap: opts must be a plain object { extract, sample, max, out, flushMs, id }'); + } + for (const k of Object.keys(opts)) { + if (!['extract', 'sample', 'max', 'out', 'flushMs', 'id'].includes(k)) { + throw new TypeError( + `tap: unknown opts key "${k}" — opts is { extract, sample, max, out, flushMs, id }`); + } + } + S._capture.checkOpts(opts); + if (opts.extract !== undefined && typeof opts.extract !== 'function') { + throw new TypeError( + 'tap: extract must be a function (subject, data, topic) => any'); + } + if (opts.id !== undefined && typeof opts.id !== 'string') { + throw new TypeError('tap: id must be a string'); + } + return (async () => { + const o = { extract: summary, id: topic, ...opts }; + if (S.hooks && o.id in S.hooks) { + return JSON.stringify({ id: o.id, topic, error: 'id already names a hook' }); + } + let prior; + if (o.id in S.taps) [prior] = await S._capture.remove(S.taps, o.id); + + const entry = S._capture.open(opts); + entry.topic = topic; + entry.extract = o.extract; + + // A throw here would propagate into whatever Firefox code sent the + // notification, so nothing in the callback is allowed to escape. + entry.observer = { + observe(subject, obsTopic, data) { + try { + const extra = {}; + if (obsTopic !== topic) extra.topic = obsTopic; + if (data !== null && data !== undefined && data !== '') extra.data = data; + + let value; + try { value = entry.extract(subject, data, obsTopic); } + catch (e) { extra.error = String(e); } + + S._capture.record(entry, value, subject, extra); + } catch (e) { } + }, + }; + + Services.obs.addObserver(entry.observer, topic, false); + entry.uninstall = () => Services.obs.removeObserver(entry.observer, topic); + S.taps[o.id] = entry; + const r = { id: o.id, topic, replaced: !!prior }; + if (prior) r.prior = prior; + return JSON.stringify(r); + })(); + }; + + const untap = (id) => { + if (id !== undefined && typeof id !== 'string') { + throw new TypeError('untap: id must be a string, or omitted to remove every tap'); + } + return (async () => + JSON.stringify({ removed: await S._capture.remove(S.taps, id) }))(); + }; + + S.tap = tap; + S.untap = untap; +})(); diff --git a/scripts/generate-moz-package.mjs b/scripts/generate-moz-package.mjs index 559e6aef..a1ebac3d 100644 --- a/scripts/generate-moz-package.mjs +++ b/scripts/generate-moz-package.mjs @@ -21,7 +21,7 @@ const moz = { bin: { 'firefox-devtools-mcp-moz': './dist.moz/index.js', }, - files: ['dist.moz', 'README.md', 'LICENSE', 'scripts', 'plugins'], + files: ['dist.moz', 'kit', 'README.md', 'LICENSE', 'scripts', 'plugins'], publishConfig: { access: 'public', }, diff --git a/src/config/constants.ts b/src/config/constants.ts index 35afe5c3..e83dee91 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -10,3 +10,10 @@ export const SERVER_NAME = typeof __SERVER_NAME__ !== 'undefined' ? __SERVER_NAME__ : 'firefox-devtools'; export const SERVER_VERSION = typeof __SERVER_VERSION__ !== 'undefined' ? __SERVER_VERSION__ : 'dev'; + +// Returned to clients in the initialize result. +export const SERVER_INSTRUCTIONS = + 'For Firefox development work, a Firefox source checkout beside the live ' + + 'instance is recommended: a sparse clone with searchfox.org for tree-wide ' + + 'queries would do it. Recommended flow: author patches against the tree, ' + + 'verify them live with the kit.'; diff --git a/src/index.ts b/src/index.ts index c3d2ee05..c2f5c722 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,16 +7,19 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, CallToolRequest, } from '@modelcontextprotocol/sdk/types.js'; -import { SERVER_NAME, SERVER_VERSION } from './config/constants.js'; +import { SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION } from './config/constants.js'; import { log, logError, logDebug, setupLogFile, flushLogs } from './utils/logger.js'; import { parsePrefs, defaultProfileDir } from './cli.js'; import type { parseArguments } from './cli.js'; import { FirefoxDevTools } from './firefox/index.js'; import type { FirefoxLaunchOptions } from './firefox/types.js'; import { buildToolset } from './tools/registry.js'; +import { listKitResources, readKitResource } from './utils/kit.js'; import { errorResponse } from './utils/response-helpers.js'; type Args = ReturnType; @@ -214,18 +217,35 @@ export async function run( logDebug(` Viewport: ${args.viewport.width}x${args.viewport.height}`); } + // The kit ships only in the moz package and is useless without privileged + // eval, so the resources capability is advertised only when it is really there. + const kitResources = allowPrivileged ? listKitResources() : []; + if (kitResources.length > 0) { + log(`Exposing ${kitResources.length} kit resources`); + } + const server = new Server( { name: SERVER_NAME, version: SERVER_VERSION, }, { - capabilities: { - tools: {}, - }, + capabilities: kitResources.length > 0 ? { tools: {}, resources: {} } : { tools: {} }, + instructions: SERVER_INSTRUCTIONS, } ); + if (kitResources.length > 0) { + server.setRequestHandler(ListResourcesRequestSchema, async () => { + log('Listing available resources'); + return { resources: kitResources }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return { contents: [readKitResource(request.params.uri)] }; + }); + } + // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { log('Listing available tools'); diff --git a/src/tools/firefox-management.ts b/src/tools/firefox-management.ts index 5950fec2..46aa0cb6 100644 --- a/src/tools/firefox-management.ts +++ b/src/tools/firefox-management.ts @@ -218,7 +218,7 @@ export const restartFirefoxTool = { type: 'string', }, description: - 'New environment variables in KEY=VALUE format (optional, e.g., ["MOZ_LOG=HTMLMediaElement:5", "MOZ_LOG_FILE=/tmp/ff.log"])', + 'New environment variables in KEY=VALUE format (optional, e.g., ["MOZ_LOG=HTMLMediaElement:5", "MOZ_LOG_FILE=/tmp/ff.log"]). Unlike prefs, env does not merge: it replaces the map set at launch, so repeat every variable you still need — MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 included, if privileged access came through it.', }, headless: { type: 'boolean', diff --git a/src/tools/privileged-context.ts b/src/tools/privileged-context.ts index 5ad1c02f..f8a3c744 100644 --- a/src/tools/privileged-context.ts +++ b/src/tools/privileged-context.ts @@ -3,6 +3,7 @@ * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 */ +import { KIT_DOC_NAMES, KIT_FILE_NAMES, readKitFile, readKitFiles } from '../utils/kit.js'; import { successResponse, errorResponse, previewExcerpt } from '../utils/response-helpers.js'; import { validateFunction } from '../utils/js-validation.js'; import { remoteValueToNative } from '../utils/remote-value.js'; @@ -46,10 +47,14 @@ export const selectPrivilegedContextTool = { }, }; +// The limits in the description re-verify per release: a >16 KB +// functionDeclaration must be rejected, a 9 s busy script must hit the BiDi +// timeout, and a kit guard's TypeError must arrive with name intact but +// failing instanceof TypeError. export const evaluatePrivilegedScriptTool = { name: 'evaluate_privileged_script', description: - 'Execute JS function in a privileged (chrome) browsing context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Get context ids from list_privileged_contexts.', + 'Execute JS function in a privileged (chrome) browsing context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Get context ids from list_privileged_contexts. Channel limits (measured on Firefox Nightly 155): payloads over ~16 KB are rejected; a script has ~8 s of real time before the 10 s BiDi timeout kills the call; only JSON crosses, and an error thrown in another realm (the kit sandbox, for one) fails instanceof — match it by e.name.', annotations: { readOnlyHint: false, }, @@ -79,6 +84,55 @@ export const evaluatePrivilegedScriptTool = { }, }; +// The last sentence duplicates SERVER_INSTRUCTIONS: harnesses that strip +// server instructions from subagents (anthropics/claude-code#85307) still +// deliver tool descriptions. Drop it when that issue is resolved. +export const ensurePrivilegedKitTool = { + name: 'ensure_privileged_kit', + description: + 'Load the bundled kit (hook, tap, hookScript, drain, describe) onto the shared system global, reachable from every privileged (chrome) context. Calling it again resets the kit code to the shipped sources and keeps live hooks, taps and their undrained buffers. Payloads reach it with globalThis.__ffllm ??= Cu.getGlobalForObject(Services).__ffllm; each kit file header is that primitive manual; read_kit_file returns each file. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Get context ids from list_privileged_contexts. For Firefox development, pair this with a source checkout: author patches against the tree, verify them live with the kit; searchfox.org covers tree-wide queries.', + annotations: { + readOnlyHint: false, + }, + inputSchema: { + type: 'object', + properties: { + context: { + type: 'string', + description: 'Privileged browsing context ID from list_privileged_contexts', + }, + }, + required: ['context'], + }, +}; + +// Serves the manuals to clients whose harness strips the MCP resource tools +// (Claude Code background subagents since v2.1.198); same bytes as kit://. +export const readKitFileTool = { + name: 'read_kit_file', + description: + 'Read a kit source file (header manual first) or a recipe-*.md usage recipe: the same bytes kit:// serves; the sources are what ensure_privileged_kit installs. Works before any install and where MCP resources are not exposed.', + annotations: { + readOnlyHint: true, + }, + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + enum: [...KIT_FILE_NAMES, ...KIT_DOC_NAMES], + description: 'Kit file or recipe doc name', + }, + }, + required: ['name'], + }, +}; + +// The install program is kit/loader.js, shipped and readable (kit://loader.js) +// beside the sources it installs; its whole file content is the BiDi +// functionDeclaration. See its header for the sandbox design. +const KIT_LOADER_FILE = 'loader.js'; + function formatContextList(contexts: any[]): string { if (contexts.length === 0) { return 'No privileged contexts found'; @@ -253,6 +307,65 @@ export async function handleEvaluatePrivilegedScript(args: unknown): Promise { + try { + const { context } = args as { context: string }; + + if (!context || typeof context !== 'string') { + throw new Error('context parameter is required and must be a string'); + } + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + await assertPrivilegedContext(firefox, context); + + const files = readKitFiles().filter((f) => f.name !== KIT_LOADER_FILE); + if (files.length === 0) { + throw new Error('Kit not found: no kit directory next to the server bundle.'); + } + + const result = await firefox.sendBiDiCommand('script.callFunction', { + functionDeclaration: readKitFile(KIT_LOADER_FILE), + awaitPromise: true, + arguments: [{ type: 'string', value: JSON.stringify(files) }], + target: { context }, + }); + + if (result.type === EvaluateResultType.Success) { + // The loader already returns a JSON string + const json = String(remoteValueToNative(result.result)); + return successResponse('Kit loaded into chrome context:\n```json\n' + json + '\n```'); + } else if (result.type === EvaluateResultType.Exception) { + const exceptionDetails = result.exceptionDetails; + return errorResponse( + new Error( + `Kit load failed: ${exceptionDetails.text}\n\n` + + '```json\n' + + JSON.stringify(remoteValueToNative(exceptionDetails.exception), null, 2) + + '\n```' + ) + ); + } else { + return errorResponse(`Unexpected script.callFunction result type: ${result.type}`); + } + } catch (error) { + return errorResponse(error as Error); + } +} + +export async function handleReadKitFile(args: unknown): Promise { + try { + const { name } = args as { name: string }; + if (!name || typeof name !== 'string') { + throw new Error('name parameter is required and must be a string'); + } + return successResponse(readKitFile(name)); + } catch (error) { + return errorResponse(error as Error); + } +} + export const module = defineModule({ name: 'privileged', description: 'Access privileged ("chrome") contexts and list extensions.', @@ -261,6 +374,8 @@ export const module = defineModule({ [listPrivilegedContextsTool, handleListPrivilegedContexts], [selectPrivilegedContextTool, handleSelectPrivilegedContext], [evaluatePrivilegedScriptTool, handleEvaluatePrivilegedScript], + [ensurePrivilegedKitTool, handleEnsurePrivilegedKit], + [readKitFileTool, handleReadKitFile], [listExtensionsTool, handleListExtensions], ], }); diff --git a/src/utils/kit.ts b/src/utils/kit.ts new file mode 100644 index 00000000..78e44d04 --- /dev/null +++ b/src/utils/kit.ts @@ -0,0 +1,111 @@ +/** + * The kit: JS sources shipped verbatim beside the server bundle, loaded into a + * chrome context by ensure_privileged_kit and exposed as MCP resources so the + * agent can read the same source the server injects. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const KIT_URI_SCHEME = 'kit://'; + +// The schema contract for read_kit_file: static so the tool schema stays valid +// on a broken install; parity with the kit directory is pinned by tests. +export const KIT_FILE_NAMES = [ + 'call.js', + 'capture.js', + 'child.js', + 'hook.js', + 'hookscript.js', + 'introspect.js', + 'loader.js', + 'tap.js', +]; + +// Prose recipes beside the sources: served by read_kit_file and kit://, never +// shipped to the loader, never evaluated. +export const KIT_DOC_NAMES = ['recipe-rejection-observer.md']; + +const kitMimeType = (name: string): string => + name.endsWith('.md') ? 'text/markdown' : 'text/javascript'; + +// Copied verbatim into the moz package (files entry), never built by tsup, so +// it sits next to the bundle; the cwd candidate covers running from source. +export function resolveKitDir(): string | null { + const bundleDir = dirname(fileURLToPath(import.meta.url)); + for (const dir of [resolve(bundleDir, '../kit'), resolve(process.cwd(), 'kit')]) { + if (existsSync(dir)) { + return dir; + } + } + return null; +} + +export function listKitFiles(): string[] { + const dir = resolveKitDir(); + if (!dir) { + return []; + } + return readdirSync(dir) + .filter((name) => name.endsWith('.js')) + .sort(); +} + +export function listKitDocs(): string[] { + const dir = resolveKitDir(); + if (!dir) { + return []; + } + return readdirSync(dir) + .filter((name) => name.endsWith('.md')) + .sort(); +} + +// Membership in the listing is also what keeps a uri from reaching outside the +// kit directory. +export function readKitFile(name: string): string { + const dir = resolveKitDir(); + if (!dir || !(listKitFiles().includes(name) || listKitDocs().includes(name))) { + throw new Error(`Unknown kit resource: ${name}`); + } + return readFileSync(resolve(dir, name), 'utf-8'); +} + +// Read server-side so the sources travel to Firefox over BiDi: the browser may +// be on another machine, where a path would mean nothing. +export function readKitFiles(): Array<{ name: string; source: string }> { + return listKitFiles().map((name) => ({ name, source: readKitFile(name) })); +} + +export function listKitResources(): Array<{ + uri: string; + name: string; + description: string; + mimeType: string; +}> { + return [...listKitFiles(), ...listKitDocs()].map((name) => { + // Each kit file opens with a one-line statement of what it is + const firstLine = (readKitFile(name).split('\n', 1)[0] ?? '') + .replace(/^(\/\/|#)\s*/, '') + .trim(); + return { + uri: KIT_URI_SCHEME + name, + name, + description: firstLine || name, + mimeType: kitMimeType(name), + }; + }); +} + +export function readKitResource(uri: string): { uri: string; mimeType: string; text: string } { + if (!uri.startsWith(KIT_URI_SCHEME)) { + throw new Error(`Unknown resource uri: ${uri}`); + } + const name = uri.slice(KIT_URI_SCHEME.length); + return { + uri, + mimeType: kitMimeType(name), + text: readKitFile(name), + }; +} diff --git a/tests/integration/kit.integration.test.ts b/tests/integration/kit.integration.test.ts new file mode 100644 index 00000000..fd10ef94 --- /dev/null +++ b/tests/integration/kit.integration.test.ts @@ -0,0 +1,175 @@ +/** + * Integration tests for the privileged kit + * Tests with real Firefox browser in headless mode + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createTestFirefox, closeFirefox, waitFor, waitForPageLoad } from '../helpers/firefox.js'; +import { + handleEnsurePrivilegedKit, + handleEvaluatePrivilegedScript, +} from '../../src/tools/privileged-context.js'; +import { listKitFiles } from '../../src/utils/kit.js'; +import type { FirefoxClient } from '@/firefox/index.js'; +import type { McpToolResponse } from '@/types/common.js'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mockGetFirefox = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/index.js', () => ({ + getFirefox: () => mockGetFirefox(), +})); + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const fixturesPath = resolve(__dirname, '../fixtures'); +const fixtureUrl = `file://${fixturesPath}/simple.html`; + +// How a payload reaches the kit: it is anchored on the shared system global, +// not on the window the loader ran in. +const KIT = 'const S = Cu.getGlobalForObject(Services).__ffllm;'; + +// (rec) => uninstall, evaluated in every content process by hookChild. +const CHILD_SEAM = `(rec) => { + const observer = { observe: (subject, topic) => rec({ topic }) }; + Services.obs.addObserver(observer, 'content-document-global-created'); + return () => Services.obs.removeObserver(observer, 'content-document-global-created'); +}`; + +function textOf(result: McpToolResponse): string { + const item = result.content[0]; + return item?.type === 'text' ? item.text : ''; +} + +function jsonBlock(result: McpToolResponse): any { + const text = textOf(result); + if (result.isError) { + throw new Error(text); + } + const match = /```json\n([\s\S]*)\n```/.exec(text); + return match ? JSON.parse(match[1]) : undefined; +} + +async function evalChrome(context: string, fn: string): Promise { + return jsonBlock(await handleEvaluatePrivilegedScript({ function: fn, context })); +} + +async function chromeContext(firefox: FirefoxClient): Promise { + const tree = await firefox.sendBiDiCommand('browsingContext.getTree', { 'moz:scope': 'chrome' }); + return tree.contexts[0].context; +} + +describe('Privileged Kit Install Integration Tests', () => { + let firefox: FirefoxClient; + let context: string; + + beforeAll(async () => { + firefox = await createTestFirefox({ env: { MOZ_REMOTE_ALLOW_SYSTEM_ACCESS: '1' } }); + mockGetFirefox.mockResolvedValue(firefox); + context = await chromeContext(firefox); + }, 30000); + + afterAll(async () => { + await closeFirefox(firefox); + }); + + it('should install the kit and keep live state on a second call', async () => { + expect(await evalChrome(context, '() => typeof Cu.getGlobalForObject(Services).__ffllm')).toBe( + 'undefined' + ); + + const first = jsonBlock(await handleEnsurePrivilegedKit({ context })); + + expect(first.reused).toBe(false); + expect(first.loaded).toEqual(listKitFiles().filter((name) => name !== 'loader.js')); + expect(first.api).toEqual(expect.arrayContaining(['hook', 'drain', 'callChild', 'hookChild'])); + expect(await evalChrome(context, `() => { ${KIT} return typeof S.hook; }`)).toBe('function'); + + await evalChrome( + context, + `async () => { ${KIT} return JSON.parse(await S.hook('kit-install', (rec) => { rec({ n: 1 }); return () => {}; })); }` + ); + + const second = jsonBlock(await handleEnsurePrivilegedKit({ context })); + + expect(second.reused).toBe(true); + expect(second.loaded).toEqual(first.loaded); + + const drained = await evalChrome( + context, + `async () => { ${KIT} return JSON.parse(await S.drain('kit-install')); }` + ); + + expect(drained.events).toHaveLength(1); + expect(drained.events[0].v).toEqual({ n: 1 }); + }, 30000); +}); + +describe('Privileged Kit Child Process Integration Tests', () => { + let firefox: FirefoxClient; + let context: string; + + beforeAll(async () => { + firefox = await createTestFirefox({ env: { MOZ_REMOTE_ALLOW_SYSTEM_ACCESS: '1' } }); + mockGetFirefox.mockResolvedValue(firefox); + context = await chromeContext(firefox); + await firefox.navigate(fixtureUrl); + await waitForPageLoad(); + jsonBlock(await handleEnsurePrivilegedKit({ context })); + }, 30000); + + afterAll(async () => { + await closeFirefox(firefox); + }); + + it('should return a value from a content process and rethrow a child throw', async () => { + const answers = await evalChrome( + context, + `async () => { ${KIT} return await S.callChild('all', '() => ({ pid: Services.appinfo.processID, answer: 6 * 7 })'); }` + ); + + expect(answers.length).toBeGreaterThan(0); + for (const answer of answers) { + expect(answer.value).toEqual({ pid: answer.pid, answer: 42 }); + } + + const thrown = await handleEvaluatePrivilegedScript({ + function: `async () => { ${KIT} return await S.callChild(${answers[0].pid}, '() => { throw new Error("kit-child-boom"); }'); }`, + context, + }); + + expect(thrown.isError).toBe(true); + expect(textOf(thrown)).toContain('kit-child-boom'); + expect(textOf(thrown)).not.toContain('timeout'); + }, 30000); + + it('should stream records from every content process to the parent drain', async () => { + const installed = await evalChrome( + context, + `async () => { ${KIT} return JSON.parse(await S.hookChild('kit-child', ${JSON.stringify(CHILD_SEAM)}, { targets: 'all', flushMs: 50 })); }` + ); + + expect(installed.installed).toBe(true); + + await firefox.navigate(fixtureUrl); + await waitForPageLoad(); + + const events: any[] = []; + await waitFor(async () => { + const drained = await evalChrome( + context, + `async () => { ${KIT} return JSON.parse(await S.drain('kit-child')); }` + ); + events.push(...drained.events); + return events.length > 0; + }, 10000); + + expect(events.some((e) => e.v.v.topic === 'content-document-global-created')).toBe(true); + expect(events.every((e) => typeof e.v.pid === 'number')).toBe(true); + + await evalChrome( + context, + `async () => { ${KIT} return JSON.parse(await S.unhook('kit-child')); }` + ); + }, 45000); +}); diff --git a/tests/tools/privileged-context.test.ts b/tests/tools/privileged-context.test.ts index 6788baef..c880edd9 100644 --- a/tests/tools/privileged-context.test.ts +++ b/tests/tools/privileged-context.test.ts @@ -3,7 +3,10 @@ import { evaluatePrivilegedScriptTool, handleEvaluatePrivilegedScript, handleSelectPrivilegedContext, + readKitFileTool, + handleReadKitFile, } from '../../src/tools/privileged-context.js'; +import { listKitDocs, listKitFiles, readKitFile } from '../../src/utils/kit.js'; // Mock the index module (used by handler tests) const mockGetFirefox = vi.hoisted(() => vi.fn()); @@ -65,6 +68,43 @@ describe('Privileged Context Tool Definitions', () => { expect(required).not.toContain('preview'); }); }); + + describe('readKitFileTool', () => { + it('should have correct name and be read-only', () => { + expect(readKitFileTool.name).toBe('read_kit_file'); + expect(readKitFileTool.annotations.readOnlyHint).toBe(true); + }); + + it('should require name and enumerate the shipped sources plus recipe docs', () => { + const { properties, required } = readKitFileTool.inputSchema; + expect(required).toContain('name'); + expect(properties?.name.enum).toEqual([...listKitFiles(), ...listKitDocs()]); + expect(properties?.name.enum).toContain('loader.js'); + expect(properties?.name.enum).toContain('recipe-rejection-observer.md'); + }); + }); +}); + +describe('handleReadKitFile', () => { + it('should return the kit file verbatim', async () => { + const result = await handleReadKitFile({ name: 'hook.js' }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe(readKitFile('hook.js')); + }); + + it('should reject names outside the kit listing', async () => { + const result = await handleReadKitFile({ name: '../package.json' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Unknown kit resource'); + }); + + it('should reject a missing name', async () => { + const result = await handleReadKitFile({}); + + expect(result.isError).toBe(true); + }); }); describe('Privileged Context Tool Handlers', () => { diff --git a/tests/utils/kit.test.ts b/tests/utils/kit.test.ts new file mode 100644 index 00000000..75526581 --- /dev/null +++ b/tests/utils/kit.test.ts @@ -0,0 +1,47 @@ +/** + * Unit tests for kit source serving + * + * The read_kit_file tool, the kit:// resource and the file shipped beside the + * server bundle must hand out the same bytes: the agent reads a manual for the + * code ensure_privileged_kit installs, so a drift between them is a lie. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { handleReadKitFile } from '../../src/tools/privileged-context.js'; +import { + KIT_URI_SCHEME, + listKitDocs, + listKitFiles, + listKitResources, + readKitResource, +} from '../../src/utils/kit.js'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const kitPath = resolve(__dirname, '../../kit'); + +const names = [...listKitFiles(), ...listKitDocs()]; + +describe('Kit sources', () => { + it('should list every file in the shipped kit directory', () => { + expect(names.length).toBeGreaterThan(0); + expect([...names].sort()).toEqual(readdirSync(kitPath).sort()); + }); + + it('should expose one kit:// resource per file', () => { + expect(listKitResources().map((r) => r.uri)).toEqual(names.map((n) => KIT_URI_SCHEME + n)); + }); + + for (const name of names) { + it(`should serve ${name} byte-identically from tool, resource and disk`, async () => { + const onDisk = readFileSync(resolve(kitPath, name), 'utf-8'); + const fromTool = await handleReadKitFile({ name }); + + expect(fromTool.isError).toBeUndefined(); + expect(fromTool.content[0].text).toBe(onDisk); + expect(readKitResource(KIT_URI_SCHEME + name).text).toBe(onDisk); + }); + } +});