Scripts

Write JavaScript that mutates requests and responses — onRequest/onResponse hooks, mock/block/rewrite, in a sandbox. The advanced evolution of the Mock action.

Scripts are Block rules grown up. Where a block rule answers a matching request with a fixed canned response — a static Block, Drop, or Mock — a script is real JavaScript that runs on each matching request and response, so you can read and mutate the actual bytes: rewrite a request header, mock a JSON response computed per-request, patch one field of a real response body, or conditionally block based on what's inside.

It is the advanced evolution of the Mock action. A Mock returns one frozen string; a script returns whatever your code decides, this request, right now.

Admin-only. Scripts run arbitrary JavaScript inside the capture path, so authoring is gated behind the dedicated scripts capability (admin-tier, stricter than block rules). See Who can author and Roles & permissions.

The two authoring models

There are two ways to attach a script, for two different shapes of task:

ModelWhere you write itWhen to reach for it
Standalone scriptSettings → Scripts (global) · Devices → ‹device› → Scripts (per-device)A named, reusable script with its own URL match, that may run onRequest, onResponse, or both. The normal way to work.
Inline Script actionOn a Block rule — the action's 4th type, beside Block / Drop / MockA one-off bit of logic bolted onto a rule you already have. Reuses the rule's own method + pattern; no separate page.

A standalone script carries its own matcher (name, URL pattern, method, phase) and one or both hook functions. An inline script is just a code string on a block rule — it borrows that rule's method and pattern and runs as a 4th action type.

Both live in the same settings JSON that block rules do, so everything you already know about scope and Realtime carries over: changes fan out to devices and the proxy without a reconnect.

What it is, in one diagram

A request flows forward through onRequest (you can mutate it, or synthesize a response and short-circuit); the upstream reply flows back through onResponse (you can mutate it before the client sees it).

clientonRequest(req)mutate · blockupstreamserveronResponse(res, req)mutate bodyforward →← response back to client

If a hook returns nothing, the bytes pass through unchanged. Anything your script does is contained: if it throws, times out, or runs out of memory, the host quietly forwards the original bytes ("fail-open" — covered below), so a buggy script can never break a connection or reveal that traffic is being intercepted.


Using it

Create a standalone script

Open Settings → Scripts (global, applies to every device) or Devices → ‹device› → Scripts (one device only) and add a script. The fields:

FieldMeaning
NameA label for you — e.g. "Force feature flag on".
URL patternA host + path wildcard, the same syntax as block rules and breakpoints — e.g. api.doordash.com/v3/feature_flags*. Leave empty or * to match everything.
MethodOptional HTTP method (GET, POST, …). Omit it to match any method.
Phaserequest · response · both — which hooks may run. An arming optimization: pick request if you only define onRequest.
EnabledA Switch to toggle the script on/off without deleting it.
CodeThe JavaScript — define onRequest, onResponse, or both.

The editor

The code pane is a Monaco editor (the same engine as VS Code): syntax highlighting, error squiggles, and autocomplete for the whole req / res / ctx / busymate / Response contract — start typing req. or busymate. and the fields and methods pop up with inline docs. No server round-trip; the type definitions ship with the page.

Test it before it goes live (dry-run)

Beside the editor is a Test panel. Pick a real captured entry from your feed and run your script against it without touching live traffic. The panel shows:

  • A before/after diff of method, URL, headers, and body for both phases.
  • Any ctx.log(...) lines your script wrote.
  • Any thrown error, with the line number.

There are two dry-run engines and they agree by design: a fast in-browser run for instant feedback, and a server-side run in the identical sandbox the live proxy uses — so "works in Test" provably equals "works live." Always Test against a representative entry before enabling a script.

Enable / disable & scope

  • The Enabled Switch arms or disarms a script instantly — disabled scripts are skipped entirely (zero cost).
  • Global scripts (Settings → Scripts) apply to every device. Per-device scripts (Devices → ‹device› → Scripts) apply to one device only.
  • The effective set for a device is the global scripts plus that device's scripts (global ++ device), exactly like block rules. The per-device tab annotates how many global scripts are also in effect.
  • Edits fan out over Realtime — no reconnect. (They apply to new connections; an in-flight keep-alive connection keeps the config it started with.)

The inline Script action on a block rule

If you just want a snippet of logic on a rule you already have, open the Blocks editor, add or edit a rule, and set its action to Script (the 4th option after Block / Drop / Mock). The status/headers/body sub-editor is replaced by the Monaco pane, and the script runs with the rule's own method + pattern. Same code contract as a standalone script — it just inherits the rule's matcher and rides the existing Blocks save path.

See that a script ran (inspector badges)

A live script is never invisible. Open any matching request in the inspector and you'll see a badge beside the existing resent / blocked badges:

BadgeMeaning
scriptRanA script matched and ran on this entry (and mutated or synthesized it, if it returned a verdict).
scriptErrorA script threw or timed out on this entry — the original bytes were forwarded (fail-open). The error is recorded so a broken script is visible, not silent.
scriptTraceThe ctx.log(...) lines the script wrote on this entry, viewable in the inspector.

These map to scriptRan / scriptError / scriptTrace fields on the entry, so even a script that times out in production shows up flagged — you'll never wonder "did it run?"

Example scripts

All four are copy-pasteable into the Code pane. Set the pattern / method to match the relevant host.

1 · Mutate a request header

Add a debug header and strip a conditional-request header so you always get a fresh response:

js
function onRequest(req) {
  const headers = { ...req.headers, "x-debug": "1" };
  delete headers["if-none-match"];   // or set it to null — both delete the header
  return { headers };                // only "headers" is changed; everything else is untouched
}

2 · Mock a JSON response

Short-circuit a matching request with a synthetic 200 and a JSON body — the upstream is never contacted. Use busymate.mock(...) or the Response.json(...) sugar:

js
function onRequest(req) {
  if (req.path.startsWith("/v3/feature_flags")) {
    return Response.json({ flags: { newCheckout: true } }, { status: 200 });
    // equivalent: busymate.mock(200, { flags: { newCheckout: true } }, "application/json");
  }
  // no return → forward unchanged
}

3 · Rewrite a field in a real response body

Let the request reach the server, then patch one field of the JSON it returns before the client sees it:

js
function onResponse(res, req) {
  const ct = res.headers["content-type"] || "";
  if (res.status === 200 && ct.includes("json")) {
    const body = res.body.json();        // parse the decoded body
    body.balance = 99999;                // edit a field
    return { body: JSON.stringify(body) }; // host recomputes Content-Length for you
  }
}

4 · Conditionally block

Block only when a condition holds — here, a POST whose body names a banned product — and let everything else through:

js
function onRequest(req) {
  if (req.method === "POST") {
    const body = req.body.json();
    if (body && body.productId === "banned-sku") {
      ctx.log("blocked banned-sku order");
      return busymate.block(403, "Item unavailable");
    }
  }
  // otherwise: forward unchanged
}

The ctx.log(...) line above shows up as the entry's scriptTrace in the inspector and in the Test panel — your debug breadcrumb.


Reference — the API contract

Both hooks are optional, both are synchronous (see no async), and one ES2020 subset runs byte-identically across every capture source. The full contract:

js
// Either or both. SYNCHRONOUS — no async / await / Promise / timers.
 
function onRequest(req) {
  // req.method, req.url, req.host, req.path   — strings, read-only views
  // req.headers       — plain object, lowercased keys, string values
  // req.body          — BodyProxy: req.body.text() / .json() / .bytes()
  // req.bodyTooLarge  — true if the body exceeded the 1 MB cap (body then omitted/truncated)
  // req.deviceId      — OPAQUE id, NOT a JWT, NOT the auth uuid
  // ctx.state         — the ONLY cross-hook channel (onRequest → onResponse); dies with the request
  // ctx.log(...args)  — capped debug log (≤ 16 lines / ≤ 2 KB); Test panel + entry.scriptTrace only
 
  // RETURN:
  //   nothing / undefined                  → forward unchanged
  //   { method?, url?, headers?, body? }    → mutate ONLY the listed keys; a null header value deletes it
  //   busymate.block(status?, body?, contentType?)             → synthetic deny
  //   busymate.mock(status?, headers?, body?, contentType?)    → synthetic success
  //   busymate.drop()                       → tear the flow down
  //   busymate.respond(status, body, headers) / Response.json(obj, init) → ergonomic synthetic
}
 
function onResponse(res, req) {
  // res.status, res.headers, res.body (BodyProxy), res.bodyTooLarge
  // res.request       — the final, post-onRequest view of the request
  // ctx.state, ctx.log available
  // RETURN: nothing → unchanged · { status?, headers?, body? } → mutate before the client sees it
}

req / res fields

FieldOnMeaning
method url host pathreqRead-only string views of the request line.
statusresThe numeric response status.
headersbothPlain object — lowercased keys, string values. In a return mutation, a header value of null deletes that header.
bodybothA BodyProxy: .text() returns the body as a string, .json() parses it, .bytes() returns a Uint8Array-shaped view.
bodyTooLargebothtrue when the body exceeded the 1 MB cap — the body is then omitted/truncated, so check this before reading.
deviceIdreqAn opaque device id — not a JWT and not the account uuid.
requestresThe final request view, after onRequest ran.

ctx — per-request context

  • ctx.state — a plain object that is the only channel from onRequest to onResponse for the same request (stash a timestamp, a flag, a captured value). It dies with the request — there is no state that survives across requests, by design.
  • ctx.log(...args) — a capped debug log (≤ 16 lines / ≤ 2 KB). It surfaces only in the dashboard Test panel and on the entry's scriptTrace. It cannot reach the network or write entries.

The busymate.* namespace

The only host-provided global, and it is frozen:

CallEffect
busymate.block(status?, body?, contentType?)Synthetic deny (defaults to 403).
busymate.mock(status?, headers?, body?, contentType?)Synthetic success (defaults to 200).
busymate.drop()Tear the flow down — the client sees a connection failure.
busymate.respond(status, body, headers)Ergonomic synthetic response.
busymate.log(...args)Same capped ring as ctx.log.
busymate.uuid()A safe id helper.
Response.json(obj, init)Sugar → busymate.mock with application/json.

Every synthetic verdict is routed through the same response synthesizer as a Mock block rule, so it inherits the same guarantee: no x-busymate-* or framing headers leak — the synthetic response is indistinguishable from a real origin reply.

Return-value semantics

A hook's return value is interpreted as one of:

  1. Nothing (undefined) → the bytes pass through unchanged.
  2. A mutation object { method?, url?, headers?, body? } (request) or { status?, headers?, body? } (response) → only the listed keys change; a null header value deletes that header; a body change makes the host recompute Content-Length.
  3. A busymate.* verdict (block / mock / drop / respond) or Response.json(...) → a synthetic response short-circuits the flow.

Anything that fails the shape check — a wrong type, a returned Promise, an object that throws on access — is treated as malformed and fails open to the original bytes.

Synchronous only (no async/await)

Hooks are synchronous in v1. There is no async / await / Promise / setTimeout / callback — no event loop is exposed inside the sandbox, and a returned Promise is treated as a malformed result (→ fail-open). This is deliberate: it keeps the per-hook timeout a clean watchdog and the contract identical across engines. You cannot call your own server from a script (busymate.fetch does not exist — see security). Async is an explicit, additive future phase, never a quiet add.

Composition order

When several standalone scripts match the same flow they run as an ordered middleware chain: onRequest runs in array order (each script sees the previous one's mutation), and onResponse runs in reverse order — request flows forward, response flows back. An inline Script action on a block rule is evaluated first (inside the block-rule match) and short-circuits if it returns a verdict; standalone scripts run after block-rule enforcement.


Security model

Scripts are treated as untrusted code with assumed hostile intent, and the whole design subordinates to that. In plain terms:

  • Sandboxed, no escape. On the proxy each script runs inside an isolated-vm sandbox — a genuinely separate V8 isolate, not a shared realm. There is no require, process, module, fetch, XMLHttpRequest, fs, net, or timers inside it. A script cannot read the proxy's service-role key or any device secret (none exist in the sandbox), and cannot reach the network or filesystem from author-visible code — there is no busymate.fetch, because it would be an exfiltration primitive. (The proxy keeps an internal, SSRF-guarded egress-allowlist for cross-host retargets, but it is not surfaced to script authors in the dashboard or MCP, so by default a script only ever mutates traffic matching its own pattern and cannot dial out.)
  • Fail-open, always. Any throw, timeout, out-of-memory, or malformed return makes the host forward the original bytes. A runaway or hostile script can never hang a connection, crash the proxy, or emit a malformed wire response that would reveal interception. The failure is recorded as scriptError on the entry (see badges) so it is visible, not silent.
  • Hard resource caps. Every hook is bounded: 50 ms per hook, 16 MB of memory per isolate, and a 1 MB body cap (over-cap bodies arrive flagged bodyTooLarge, never materialized as an unbounded string). Hitting any cap → hard abort → fail-open. The second-order bound on latency is a 32-slot global concurrent-hook semaphore: only 32 hooks run at once across the whole proxy, and once that budget is saturated the proxy sheds to fail-open (forwards the original bytes rather than queueing) — so a flood of scripted connections can never pile work onto the event loop. There is no per-connection time wall; the concurrency budget is what keeps a script-chain from compounding latency.
  • Within the privacy chokepoint. The worst a malicious script can do is mutate or synthesize the bytes of requests/responses matching its own pattern, and write ≤ 2 KB to a debug log. It cannot emit x-busymate-* headers or break HTTP framing (those are stripped/owned by the host after the script returns), touch other devices' traffic (scripts are device-scoped via the effective union), or persist state across requests.

Who can author (RBAC)

Authoring is stricter than block rules. A dedicated scripts capability section gates it:

  • scripts:view to read scripts and run the server-side dry-run.
  • scripts:edit plus admin to write any script — standalone or an inline Script action on a block rule — and every write is confirm-gated. (Arbitrary in-MITM JavaScript is treated like the admin-only remote-browser-control tools.)

This is enforced at the database layer (a Postgres write-gate on the settings tables), so it holds no matter which surface writes — the dashboard, MCP, or a raw device token. In particular, a user with only blocks:edit cannot smuggle a script action into a block-rules write to run code. See Roles & permissions.


Per-source coverage

The API contract is frozen and runs byte-identically across every capture source — a script you write once behaves the same wherever it runs. But where a script can run, and how much of a request/response it can touch, depends on the capture engine underneath. The three sources differ in reach and in their body-size ceiling.

SourceWhere scripts runCoverageBody capNotes
proxy-server (PAC mode / non-iOS clients)The Node MITM proxy in the capture pathAll decrypted-HTTPS hosts (add them to your SSL-proxy list) + plain HTTP1 MB — bodies over the cap arrive flagged bodyTooLargeThe fullest engine — the reference implementation. Reach for it when you need everything below.
cdp-connector (bmc) (Chrome)Inside Chrome, via the DevTools Fetch domainWhatever the controlled Chrome requests1 MBSame-origin retarget only — a script can mutate or synthesize a response, but can't redirect a request to a different origin (a Fetch-domain limit).
iOS VPN mode (on-device tunnel MITM)The NEPacketTunnelProvider network extension, on the deviceOnly hosts in your SSL-Proxying list — a host you haven't opted to decrypt is passed through untouched (host name only)256 KB — bodies ≥ 256 KB are bodyTooLarge and cannot be mutatedHTTP/1.1 only — the tunnel forces ALPN http/1.1, so HTTP/2 traffic is a gap. The tight body cap and fail-open under memory pressure both fall out of the extension's ~50 MB memory budget (iOS kills an over-budget network extension).

iOS scripting is live (TestFlight build 177). The on-device engine is the vendored QuickJS-ng runtime — its public hard limits (JS_SetMemoryLimit 16 MB + a 50 ms interrupt watchdog) keep a runaway script contained well inside the extension's ~50 MB budget, verified by an on-device jetsam gate (loop + allocator adversaries both contained, footprint flat ~5 MB, the tunnel never dropped). iOS VPN-mode is still the most constrained source — the 256 KB body cap and HTTP/1.1-only reach below are real — but it is fully shipped and fails open under pressure rather than killing the tunnel.

One caveat: a newly added or edited script reaches a running tunnel only after a VPN off→on cycle (a known extension realtime-receive limitation, tracked in issue #77). The engine itself is fully live — toggle the VPN after saving a script.

When iOS VPN mode can't do it — use PAC mode

If you need scripting on something the on-device tunnel can't handle, route that device through the proxy-server (PAC mode) instead — same scripts, the fuller engine. Switch when you need either of:

  • Bodies larger than 256 KB — the proxy's cap is 1 MB (vs 256 KB on iOS), so larger payloads stay mutable.
  • A host you'd rather not MITM on-device — PAC routes the device's traffic to the proxy, so the decryption + scripting happens off-device.

Switch a device's connection mode to PAC and the same scripts apply, with the proxy's full reach and the higher body ceiling.

HTTP/2 is a gap on both sources, not just iOS. The proxy MITM also advertises ALPN http/1.1 (h2-capable clients downgrade), exactly like the on-device tunnel — so switching to PAC does not add HTTP/2 scripting. HTTP/2-native flows are out of scope everywhere until the framer lands.

Roadmap

The scripting engine is now complete across every capture source — proxy-server, cdp-connector (bmc), and iOS VPN mode — plus full read/write parity over the dashboard and MCP (get_scripts / set_scripts_global / set_scripts_device / dry_run_script). Issue #79 (the cross-source umbrella) is closed: the final surface, iOS, shipped in TestFlight build 177 on the vendored QuickJS-ng engine. The frozen API contract means a script you write today runs unchanged on every source above. Remaining polish: live script propagation to a running iOS tunnel still needs a VPN cycle (#77), and HTTP/2 scripting awaits the h2 framer (a gap on both MITM sources).

Next