SLOPSHOPPER

Wirepane

The debugging proxy Claude can read, inside Claude Code on macOS: HTTPS, HTTP/2, gRPC, WebSockets and server-sent events from the browser, iOS and Android…

newpaneguardcommandtoaststatus
v0.9.2MITupdated 2026-10-09legostin/wirepane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wirepane
│ ┃ Proxy ✕ › fix the failing auth test and add an audit log call │ ┃ ✕ not working · 0 requests │ ┃ [ Start ] View [ List ] [ Tree ] [ Domains: ⏺ Read(src/auth.ts) │ ┃ Node.js 18 or newer runs the proxy, and ⎿ Read 6 lines │ ┃ there is none on PATH or where Homebrew, ⏺ Update(src/auth.ts) │ ┃ Volta, nvm, fnm, mise, asdf, nodenv or ⎿ Added 2 lines, removed 1 line │ ┃ MacPorts put it. ⏺ Bash(bun test) │ ┃ [ Download Node.js ] nodejs.org: install it, ⎿ 3 pass, 1 fail │ ┃ Filter : method:POST status:4xx host:*.api.c │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Quick start ✻ Worked for 42s · done 4:20 PM │ ┃ │ Proxy [ Start ] runs it on this Ma │ ┃ │ Browser no Chrome, Edge, Brave or Ch › /proxy │ ┃ │ Phone [ Set up a phone ] its addre ⎿ wirepane: The Proxy pane is open. │ ┃ │ Only your app [ Track domains ] record onl │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ wirepane: ⇄ proxy: failed (/proxy)

Draws

Pane · Proxy
✕ not working · 0 requests [ Start ] View [ List ] [ Tree ] [ Domains: all ] [ Rules (0 Node.js 18 or newer runs the proxy, and there is none on PATH or where Homebrew, Volta, nvm, fnm, mise, asdf, nodenv or MacPorts put it. [ Download Node.js ] nodejs.org: install it, then press Star Filter : method:POST status:4xx host:*.api.com type:json is: ╭─────────────────────────────────────────────────────────── │ Quick start │ Proxy [ Start ] runs it on this Mac │ Browser no Chrome, Edge, Brave or Chromium in /Appli │ Phone [ Set up a phone ] its address and a QR code │ Only your app [ Track domains ] record only its hosts ╰───────────────────────────────────────────────────────────
README

Wirepane: the debugging proxy that Claude can read

CI License: MIT Claude Code mod

Wirepane turns Claude Code into an HTTPS debugging proxy for the browser, the iOS Simulator, iPhones, the Android emulator and Android phones. It is a mod: a plugin of function hooks. It decrypts HTTP/1.1, HTTP/2, gRPC, WebSockets and server-sent events, shows them in a pane next to your conversation, and gives Claude the same traffic through 16 tools.

So instead of copying requests into the chat, you ask:

"Why does checkout return 402 on the phone but not in the browser?" "Wait while I tap Log in, then tell me what the app sent." "Make the feed take 3 seconds and fail one request in five." "Answer the price socket with a mock that sends a tick every subscription." "Nothing shows up from the emulator. Fix it."

Claude finds the request, reads only the part it needs, compares the one that works with the one that fails, replays it with a change, writes a rule, and fixes your code. When something is in the way, a missing CA, a pinned certificate, a VPN or Charles holding the system proxy, the doctor names it and fixes it.

Website · Install · What Claude can do · Doctor · Rules · Limits · Design notes

Install

You need macOS, Claude Code 2.1.292 or newer, and openssl (built into macOS).

The proxy runs on Node.js 18 or newer and finds it by itself: on PATH, or where Homebrew, Volta, nvm, fnm, mise, asdf, nodenv or MacPorts put it. With no Node at all, the pane offers to install it.

At the Claude Code prompt:

/plugin install wirepane --marketplace legostin/wirepane

Or from a shell:

claude plugin marketplace add legostin/wirepane
claude plugin install wirepane@wirepane

Then run /proxy. The proxy starts on 127.0.0.1:8899, the pane opens, and an empty list offers the ways in it found on this Mac: a browser, a booted simulator, an emulator, a phone.

● 127.0.0.1:8899 · 342 requests          [ Stop ]  View [ List ] [ Tree ]  [ Clear ] [ Setup ] [ Rules (2) ] [ Health (1) ]
Filter host:*.api.example.com is:error
401  POST    https://api.example.com/v1/login                     1.2KB   180ms
200  POST    ✎ https://api.example.com/pkg.Cart/Checkout             41B    95ms
101  GET     wss://api.example.com/chat                          ↑14 ↓52       …
CERT CONNECT gateway.icloud.com:443                                   0B     0ms

What is in it

It reads every protocol a modern app speaks.

  • HTTP/2 to clients that offer it, and to servers that speak it. HTTP/1.1 for the rest, both ways independently. Plain-text HTTP/2 (h2c, prior knowledge) too, as a gRPC client speaks it to a local service through the proxy.
  • gRPC and gRPC-Web, with trailers forwarded. Bodies are decoded without a schema: field numbers, values, nested messages. A failed call shows its status, gRPC NOT_FOUND: no such user.
  • Protobuf bodies (application/x-protobuf), decoded the same way.
  • WebSockets, message by message, both ways, with timing and close codes. Compression is taken out of the offer so every message stays readable. Binary messages are read without a schema: protobuf (bare, after its varint length, or in a gRPC frame), text, and gzip or zlib around them; base64 only when nothing fits. In the detail of a live socket, you can type a message to either side.
  • Server-sent events, event by event, as they arrive, with the millisecond each one came. Made for LLM streaming APIs.
  • gzip, brotli, deflate and zstd, decoded.

It reaches every client in one press.

  • A separate browser: Chrome, Edge, Brave or Chromium, trusting the proxy by SPKI hash. No certificate to install.
  • iOS Simulator: boots, gets the CA, the system proxy turns on.
  • Android emulator: starts behind the proxy and opens the CA page. On Google APIs images, Trust in all apps makes the CA a system CA.
  • Phones: the exact address to type and a QR code. adb reverse for Android phones on USB, so no Wi-Fi is needed.

It changes traffic, not just shows it. A rules engine for requests, responses and WebSocket messages:

  • breakpoints: a request or a response held until you or Claude let it go, changed or not;
  • mocks, delays, throttling, errors, dropped connections;
  • rewritten headers, URLs and JSON;
  • sending requests to your local server;
  • a whole mock WebSocket server.

Claude writes rules in plain words. You manage them in the Rules view.

It works on office networks. An upstream proxy carries every connection to the servers: HTTP, HTTPS, HTTP/2, tunnels and WebSockets. It can be an HTTP proxy that signs in with Basic or Windows NTLM credentials, SOCKS5, or a PAC file that picks per URL. The doctor offers the network's own proxy for it.

It tells you what is wrong, and fixes it. The doctor checks the proxy, the system proxy, VPNs, other proxy apps, the CA on each client, pinned hosts, upstream failures and Android devices. Each finding names its fix, and many have a button. The proxy repairs some things on its own:

  • Pinned hosts pass through after two refusals, so the app keeps working.
  • A system proxy left on by a killed proxy is put back by a watchdog.
  • A self-signed dev server gets its certificate accepted in one press, for that host only.

It keeps Claude's context small. Long URLs are cut, requests are read part by part, and bodies share one budget. json_path picks one field of a big body. since and wait_for_request replace polling.

One proxy, every session. A single proxy serves every Claude Code session on the Mac:

  • A second session attaches and sees what the first recorded.
  • Each project's rules apply while its session is open.
  • The Health view shows the process, its memory and the sessions using it.

It runs locally.

  • No account, no telemetry, no npm dependencies.
  • Its CA is made on your machine.
  • Recordings stay in ~/.claude/proxy-mod.

Tools for Claude

ToolWhat it does
list_requests({ filter?, since?, limit? })The proxy's state and one line per request: id, method, status, URL (long ones cut), size, time, type, WebSocket and event counts, rules, error. since lists only what is new.
get_request({ id, part?, json_path?, max_chars?, from?, limit? })One request, by part: summary, headers, request, response, messages, all. Bodies share one budget. JSON is compact when pretty would not fit, and json_path picks one field. gRPC, protobuf, trailers, WebSocket messages and events are included.
search_requests({ text, where?, filter? })Which requests hold some text in their URL, headers, bodies, messages or events, with the text in context.
wait_for_request({ filter, timeout_s?, until? })Waits for the request the person is about to trigger, and answers it the moment it ends.
diff_requests({ a, b })What differs between two requests: method, URL, query, headers, status, JSON field by field.
replay_request({ id, method?, url?, headers?, body?, json? })Sends a request again, as it was or changed, through the proxy, so it is recorded and rules apply.
resume_request({ id, action?, changes?, respond? })Lets go of an exchange held at a breakpoint: as it was, changed (method, URL, headers, body, status), answered by hand, or cut.
`send_ws_message({ id, to, text \json \b64 })`Injects a message into a live WebSocket, to the client or to the server.
close_websocket({ id, code?, reason? })Closes a live WebSocket, to test reconnects.
add_rule, update_rule, remove_rule, list_rulesThe rules file, applied at once.
track_domains({ add?, remove?, set?, enabled? })Decrypt and record only the app's hosts, and see what passed through.
export_har({ filter?, file? })HAR 1.2 with bodies and WebSocket messages, for a teammate or Chrome DevTools.
diagnose()The doctor: every check, each finding with its fix.

The plugin also ships three skills that Claude loads when the task calls for them:

  • wirepane-debugging: the order that works, from filter to summary to part, then search, diff, replay and verify. Also how to read gRPC, WebSocket and SSE traffic cheaply.
  • wirepane-troubleshooting: symptom, check, fix, for everything the doctor knows. It includes the fix in your own app: Android network_security_config, OkHttp and iOS pinning in debug builds, Flutter HttpOverrides, and proxy settings for Node, Go, Python, Java, Docker and Unity.
  • wirepane-rules: recipes for mocks, latency, chaos, JSON rewrites, GraphQL operations and WebSocket mocks. Every one is tested to validate.

The doctor

/proxy doctor, the Health view (h), or Claude's diagnose check:

CheckFindsFix
The proxy and its processStopped, failed, a port taken (and by whom; a Wirepane 0.7 proxy still running after an update); pid, uptime, memory, disk, sessionsStart again, Stop it and start, Restart
The system proxyLeft on by a dead proxy (no internet); held by Charles or ProxymanPut back; Setup
VPN and other proxy appsA utun default route; Charles, Proxyman, mitmproxy, HTTP Toolkit runningWhat to try
The CA on this MacSafari and Mac apps would refuse HTTPSTrust on this Mac
Refusals per clientEvery host refused: the CA is missing. One host among working ones: it pins its certificateSetup for that client, or Never decrypt it
Pinned hostsPassed through after two refusals (or an OkHttp-style close right after the handshake)Decrypt them again once trusted
Upstream failuresDNS (ENOTFOUND), self-signed dev servers, closed ports, localhost confusion, unreachable networksAccept its certificate; what to check
The network's own proxyThe system proxy pointed at an office's or a VPN's proxy before Wirepane took its placeUse it upstream
Tracked domainsA list that matches nothing that came, and what passed insteadAdd the real hosts
Android devicesNot pointed at the proxy; apps that will not trust a user CA; a rootable imageSetup; Trust in all apps

Set up a client

ClientHow
A separate browserSetup → Browser → Open Google Chrome (or Edge, Brave, Chromium). It starts a new instance with a profile of its own:<br>--proxy-server<br>--proxy-bypass-list=<-loopback>, so localhost is captured too<br>--ignore-certificate-errors-spki-list, so it trusts the proxy without the keychain<br>HTTP/2 and WebSockets work as they do anywhere.
iOS SimulatorSetup → iOS: Use (or Boot & use) boots one, adds the CA (simctl keychain add-root-cert) and turns on the macOS system proxy, since a simulator has no proxy setting of its own.
iPhone / iPadSetup → iOS → iPhone / iPad:<br>1. Listen on LAN.<br>2. Scan the QR code to install the profile.<br>3. Turn on full trust under Certificate Trust Settings.<br>4. Set the Wi-Fi proxy to the address shown.<br>The section confirms when traffic arrives.
Android emulatorSetup → Android → Emulator: Start through the proxy launches an AVD with -http-proxy and opens the CA page in its browser.<br>Apps trust a user CA only with a network_security_config. On a Google APIs image, Health → Trust in all apps makes the CA a system CA until the next reboot.
Android phoneSetup → Android → Phone: on USB, Point USB phones at the proxy (adb reverse, no Wi-Fi needed); on Wi-Fi, Listen on LAN, the QR code, and the proxy setting shown.
Safari and Mac appsSetup → macOS → Turn on for this Mac (Claude's hosts bypass it) and Trust CA on this Mac. Both are put back when the proxy stops.
curl, Node, Python, Go, Java, DockerSetup → CLI copies HTTPS_PROXY, NODE_EXTRA_CA_CERTS, SSL_CERT_FILE and REQUESTS_CA_BUNDLE. The troubleshooting skill covers the clients that ignore the proxy.

Claude Code keeps working

Claude Code does not trust the proxy's CA, and with the system proxy on, its own traffic would come here. Two guards keep it whole:

  • Claude's hosts are bypassed. Anthropic's and Claude's hosts are on the system proxy's bypass list, and the proxy never decrypts them anyway.
  • Claude's processes are tunnelled. A local connection from a process that descends from Claude Code (the commands and MCP servers it runs, the Claude app) is tunnelled untouched.

Rules

Rules change matching requests before they are sent, responses before the client gets them, and WebSocket messages both ways.

  • Writing them. Ask Claude in plain words and it writes the rule. The Rules view (r) lists them in words, with hit counts. It turns them on and off, reorders them, and removes them.
  • Where they live. In the project, in .claude/proxy-rules.json, so you can commit them. The file is reloaded the moment it changes.
  • Order. The first rule applies first. Every matching rule applies in turn; "stop": true ends the chain.
{
  "rules": [
    {
      "id": "slow-feed",
      "description": "The feed on a bad 3G line, failing now and then",
      "match": { "methods": ["GET"], "host": "api.example.com", "path": "/v1/feed*" },
      "request": [{ "type": "delay", "ms": 800, "msMax": 2500 }],
      "response": [{ "type": "throttle", "bytesPerSecond": 50000 }]
    },
    {
      "id": "mock-prices-socket",
      "description": "A price feed, with no server",
      "match": { "path": "/ws/prices" },
      "request": [{ "type": "respond", "status": 101 }],
      "messages": [
        { "type": "send", "on": "open", "to": "client", "json": { "type": "hello" } },
        { "type": "reply", "when": "\"subscribe\"", "json": { "type": "price", "price": 42.5 } }
      ]
    }
  ]
}

Match. Every condition given must hold:

  • url, host and path take globs (*.example.com, /v1/*) or re:<regex>;
  • methods, headers, query and bodyContains;
  • on the response side, status (404, 4xx, >=400, 500-599) and contentType.
ActionRequestResponse
delay {ms, msMax?}, throttle {bytesPerSecond}✓✓
setHeader, removeHeader✓✓
setQuery, removeQuery, mapRemote {scheme?, host?, port?, path?}, replaceUrl✓
`setBody {text \json \file}, replaceBody {pattern, with}, mergeJson {json}`✓✓
`respond {status, headers?, text \json \file}`: no server asked (101 on a WebSocket: a mock server)✓
setStatus {status}✓
`fail {kind: reset \close \timeout}`✓✓
breakpoint {timeoutMs?}: held until you (the Held view) or Claude (resume_request) let it go, changed or not; after its time (5 min) it goes on as it was✓✓
script {code}✓✓
WebSocket step (messages)What it does
`direction: out \in \both, when: "text" \"re:…", on: open`Which messages it takes (or once, as the socket opens)
replaceMessage {pattern, with}, `setMessage {text \json \file}, mergeJson {json}`Change the message
drop, delay {ms, msMax?}Lose it, or hold it
`reply {text \json}`Answer the sender; the message goes no further
`send {to, text \json}`One more message, to the client or the server
close {code?, reason?}Close both sides
script {code}async (msg, ctx) => {}: change msg.text, msg.drop = true, ctx.send(to, …), ctx.close(…)

Bodies. A response body is held back only when a rule changes it, so server-sent events keep streaming under header rules.

Scripts. A script runs only once its SHA-256 is approved. A rules file cloned with a repository cannot run code unasked; press Allow script. Scripts Claude adds through its tools are approved with them.

In the list. A request a rule changed is marked ✎, and its detail says what each rule did. Filter with is:modified or rule:<id>.

Filter

Terms separated by spaces must all hold; a leading - negates one. Free text matches a substring of the URL.

TermMatches
method:POST, method:get,postthe method
status:404, status:4xx, status:>=400, status:500-599the response status
host:api.example.com, host:*.example.comthe host
path:/v1/logina substring of the path
`type:json\html\xml\js\css\img\font\media\text\form\grpc\ws\tunnel\other`the content type
`is:error\ok\pending\tunnel\ws\https\h2\grpc\held\rejected\modified`the state (error includes failed gRPC calls; held: waiting at a breakpoint)
client:192.168.1.20, rule:slow-feedthe client, a rule

A request's detail

A row opens its detail: the status, the timing, what the rules did, and tabs for Request and Response, plus Messages for a WebSocket and Events for a stream (keys 1 to 4). It opens on the response, or on the messages or the events.

Find searches the tab you are on, case aside: the headers and the body as shown (pretty JSON), or the messages. Every match is marked, the current one brighter, with 3 of 14 · line 340. Enter or j goes to the next, k to the one before, and the body scrolls to it, however deep. It stays as you switch tabs, so you can look for the same token in the request and the response.

Tracked domains

A phone talks to dozens of hosts. Turn on the tracking list and the proxy decrypts and records only your app's domains. Everything else passes through untouched and unrecorded.

/proxy track app.example.com *.example.com     track these (*.example.com covers example.com)
/proxy untrack app.example.com                 stop tracking one
/proxy untrack                                 every domain again

The Domains view (d) shows the hosts that passed through, busiest first, each with a track button. Clients that connect to an address (the Android emulator) are tracked by the name in their TLS handshake.

One proxy for every session

The first session that needs the proxy starts it detached; the others attach to it.

  • What a session gets on attaching: the requests recorded so far, the tracked domains, the hosts passed through, and the rules of its own project.
  • Sessions share the proxy:
  • Each project's rules apply while its session is attached.
  • The tracked domains are the proxy's.
  • /clear and --resume keep everything in place.
  • When it stops:
  • /proxy stop stops it for everyone and says how many other sessions it served.
  • A session that ends only lets go; the last one out stops it.
  • With every session gone, it waits 90 seconds, then puts the system proxy and Android devices back and exits.
  • New settings: a proxy of another version or with other settings is replaced once nobody uses it. /proxy restart replaces it at once.
/proxy            open the pane and start (or attach to) the proxy
/proxy setup      set up a browser, iOS, Android, macOS or CLI client
/proxy doctor     the doctor's findings, and the Health view
/proxy rules      the rules
/proxy track      the tracked domains
/proxy export     write a HAR file (.claude/wirepane-<time>.har, or the path given)
/proxy restart    restart the proxy
/proxy stop       stop it (and point the system proxy and Android devices back)
/proxy clear      clear this session's list
/proxy status     one line about its state
/proxy tree|list  the requests as a tree (host → path) or a list

Options

Set them in /config, or in /plugin → Installed → wirepane → Configure options.

OptionDefault
Proxy port8899
Proxy reachable fromlocallan opens it to phones on the network (never to this Mac's localhost)
Hosts never decrypted*.apple.com,*.icloud.com,*.mzstatic.com,*.apple-cloudkit.comtunnelled untouched; hosts that refuse the certificate twice are added on their own
Hosts with unchecked certificates(none)dev servers with self-signed certificates, by host
Upstream proxy (office, VPN)(none)an office's or a VPN's proxy every connection to a server goes through: http://[user:password@]host:port (Basic or NTLM, whichever it asks for; a Windows DOMAIN\user as DOMAIN%5Cuser), socks5://[user:password@]host:port, or a PAC file as pac+http://[user:password@]host/proxy.pac (the credentials go to the proxies it names, not to the PAC's server)
Hosts that skip the upstream proxy(none)hosts reached without the upstream proxy; this Mac's own addresses and *.local always are
Requests to keep2000

How it works

session A ──spawns──▶ node sidecar/attach.mjs ─┐                         ┌── browser
session B ──spawns──▶ node sidecar/attach.mjs ─┼─ events ─▶ node sidecar/proxy.mjs --daemon ◀── :8899 ── simulator
   pane · tools · status line                  └─ commands ─▶ (one per Mac, detached)  └── phone · emulator
                                                       │ headers, bodies, messages → ~/.claude/proxy-mod/flows/
                                                       │ watchdog: puts the system proxy back if it is killed

A mod runs sandboxed, with no sockets of its own, so the proxy is a small Node.js process.

  • TLS. It terminates TLS with a certificate per host (397 days at most), signed by a CA made on your machine. ALPN gives each client HTTP/2 or HTTP/1.1 on its own, and each server too.
  • What reaches the mod. Request summaries stream to the mod as JSON lines. Headers, bodies, WebSocket messages and events stay on disk until the pane or Claude reads them.

docs/design.md has the details.

What it runs and what it changes

Wirepane sends nothing of its own anywhere: no telemetry, no update checks, no downloads. It connects only to the servers the clients you point at it asked for. A request reaches Claude only when Claude calls a tool; what it reads then becomes part of the conversation, like a file Claude reads.

While the proxy is on, it runs:

  • The proxy: node sidecar/proxy.mjs --daemon on 127.0.0.1 (on the network too in lan mode). It is detached so other sessions can use it, and stops when the last session leaves.
  • The session links: node sidecar/attach.mjs, one per session, which passes the proxy's events to the mod.
  • The watchdog: node sidecar/watchdog.mjs, which puts the system proxy back if the proxy is killed.
  • openssl: makes the CA and the host certificates.
  • System tools: route, networksetup and scutil find this Mac's addresses and read the system proxy. ps and lsof tell Claude's own connections apart. The doctor uses ps, lsof and adb too.

Only when you press the button for it:

In the paneRunsChanges
macOS → Turn on for this Mac, iOS → Usenetworksetup, through osascript when macOS asks for an administratorThe system proxy, put back when the proxy stops
macOS → Trust CA on this Macsecurity add-trusted-cert, which macOS asks you to confirmThe CA in your login keychain
iOS → Use, Boot & usexcrun simctl, open -a SimulatorThe CA in that simulator's keychain
Browser → Openopen -na <browser> with a profile of its ownNothing outside ~/.claude/proxy-mod/browser

| Android → Start through the proxy, Android → proxy, Point USB phones

Source 12 files
hooks/register.tsx 3740 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import { describeRule, isTracked, matchesHostPattern, normalizeHostPattern, parseRules, ruleErrors, scriptsOf, wildcardFor } from '../shared/rules.mjs'
5import type { Rule } from '../shared/rules.mjs'
6import type {
7  ProxyAndroidDevice,
8  ProxyDevices,
9  ProxyFlow,
10  ProxyHealth,
11  ProxyHeld,
12  ProxyRuleEntry,
13  ProxyRules,
14  ProxySession,
15  ProxySetupTab,
16  ProxySimulator,
17  ProxyStatus,
18  ProxySystemProxy,
19  ProxyTracking,
20  ProxyView,
21} from '../types'
22import {
23  asAdminScript,
24  enableCommands,
25  needsAdmin,
26  parseBypass,
27  parseProxyState,
28  parseServiceOrder,
29  restoreCommands,
30} from '../shared/systemproxy.mjs'
31import type { SystemProxyBackup } from '../shared/systemproxy.mjs'
32import {
33  buildTree,
34  clip,
35  type FlowBody,
36  type FlowDetail,
37  filterFlows,
38  flattenTree,
39  flowTable,
40  flowUrl,
41  formatDuration,
42  formatSize,
43  grpcLabel,
44  isTextual,
45  languageOf,
46  matchesFilter,
47  mergeFlows,
48  modelUrl,
49  parseRecords,
50  type SseRecord,
51  findMatches,
52  findWindow,
53  sseLine,
54  splitByMatches,
55  streamNote,
56  type WsRecord,
57  wsLine,
58  parseEvent,
59  parseFilter,
60  prettyBody,
61  splitLines,
62  statusLabel,
63  toCurl,
64  treeIds,
65  treeLeafLabel,
66  truncate,
67} from './flows'
68import { DEVICE_CA, hasSystemCaCommand, isRootRefused, systemCaScript } from './android'
69import { diffRequests } from './diff'
70import { type AndroidFacts, diagnose, type DoctorAction, type DoctorFacts, findingsText } from './doctor'
71import { toHar } from './har'
72import { bodyForModel, busiestHosts, clipValue, jsonPath, splitBudget } from './model'
73import { encodeQr, qrRaster, qrSvg } from './qr'
74import {
75  androidGuide,
76  BROWSER_CANDIDATES,
77  type Browser,
78  browserArgs,
79  browserGuide,
80  cliGuide,
81  iosGuide,
82  MIN_NODE,
83  newestNodeFirst,
84  NODE_DOWNLOAD,
85  nodeMajor,
86  phoneAddress,
87  proxyTargets,
88  SETUP_TABS,
89  type SetupFacts,
90  setupCommands,
91} from './setup'
92
93// --- state ----------------------------------------------------------------------
94// The atoms are written here too: the engine reads every state reference off
95// this file's own consts.
96
97const PANE = 'proxy'
98
99const STOPPED: ProxyStatus = {
100  phase: 'stopped',
101  host: '127.0.0.1',
102  port: 8899,
103  addresses: [],
104  lan: [],
105  runDir: null,
106  pid: null,
107  ca: null,
108  error: null,
109}
110
111const flowsAtom = atom({ plugin: 'wirepane', key: 'flows' } as const, [] as ProxyFlow[])
112const statusAtom = atom({ plugin: 'wirepane', key: 'status' } as const, STOPPED)
113const viewAtom = atom({ plugin: 'wirepane', key: 'view' } as const, {
114  mode: 'list',
115  selectedId: null,
116  setupTab: 'browser',
117  layout: 'list',
118} as ProxyView)
119const filterAtom = atom({ plugin: 'wirepane', key: 'filter' } as const, '')
120const wantedAtom = atom({ plugin: 'wirepane', key: 'wanted' } as const, false)
121const nextIdAtom = atom({ plugin: 'wirepane', key: 'nextId' } as const, 1)
122const noticeAtom = atom({ plugin: 'wirepane', key: 'notice' } as const, '')
123/** Emulators this session pointed at the proxy, to point back on stop. */
124const emulatorsAtom = atom({ plugin: 'wirepane', key: 'emulators' } as const, [] as string[])
125/** The tree view's open nodes, by TreeNode id. */
126const expandedAtom = atom({ plugin: 'wirepane', key: 'expanded' } as const, [] as string[])
127/** The proxy's tracked domains, which every attached session shares; off or empty, every domain is tracked. */
128const trackingAtom = atom({ plugin: 'wirepane', key: 'tracking' } as const, { enabled: false, patterns: [] } as ProxyTracking)
129/** Hosts that passed through untracked since the proxy started, with counts. */
130const skippedAtom = atom({ plugin: 'wirepane', key: 'skipped' } as const, {} as Record<string, number>)
131const devicesAtom = atom({ plugin: 'wirepane', key: 'devices' } as const, {
132  simulators: [],
133  simulatorError: null,
134  avds: [],
135  android: [],
136  androidError: null,
137} as ProxyDevices)
138const systemProxyAtom = atom({ plugin: 'wirepane', key: 'systemProxy' } as const, { isOn: false, service: null, isOurs: false } as ProxySystemProxy)
139const caSimulatorsAtom = atom({ plugin: 'wirepane', key: 'caSimulators' } as const, [] as string[])
140const busyAtom = atom({ plugin: 'wirepane', key: 'busy' } as const, '')
141/** This mod's version, from its plugin.json: which copy the session runs. */
142const versionAtom = atom({ plugin: 'wirepane', key: 'version' } as const, '')
143const macTrustAtom = atom({ plugin: 'wirepane', key: 'macTrust' } as const, 'unknown' as 'unknown' | 'trusted' | 'untrusted')
144/** The project's rules file as last read. */
145const rulesAtom = atom({ plugin: 'wirepane', key: 'rules' } as const, { file: null, entries: [], fileErrors: [] } as ProxyRules)
146/** The sessions attached to the shared proxy. */
147const sessionsAtom = atom({ plugin: 'wirepane', key: 'sessions' } as const, [] as ProxySession[])
148/** Hosts the proxy passes through after they refused the certificate. */
149const pinnedAtom = atom({ plugin: 'wirepane', key: 'pinned' } as const, [] as { client: string | null; host: string }[])
150/** Exchanges held at a breakpoint now, until someone lets them go. */
151const heldAtom = atom({ plugin: 'wirepane', key: 'held' } as const, [] as ProxyHeld[])
152const healthAtom = atom({ plugin: 'wirepane', key: 'health' } as const, { checkedAt: null, findings: [], process: null } as ProxyHealth)
153
154// Every function that takes `$` lives in this file: the engine follows `$`
155// into functions of the hooks module itself, never across an import.
156
157// --- the sidecar and the machine ------------------------------------------------
158
159// The sidecar's life, the flow list's updates, and the actions the setup
160// tabs run on this machine (simulators, emulators, a browser).
161
162
163
164type Options = {
165  port: number
166  listen: 'local' | 'lan'
167  noDecrypt: string
168  maxFlows: number
169  /** Upstreams whose certificate is accepted unchecked: dev servers with self-signed ones. */
170  insecureHosts: string
171  /** An office's or a VPN's proxy every connection to a server goes through, and the hosts reached directly. */
172  upstreamProxy: string
173  upstreamBypass: string
174}
175
176type Runtime = {
177  pid: number | null
178  isStopping: boolean
179  pending: Map<number, ProxyFlow>
180  isFlushScheduled: boolean
181  stderr: string
182  /** This session's attach process (attach.mjs): killing it lets go of the shared proxy. */
183  clientPid: number | null
184  /** Stopping the proxy for every session, not only letting go of it. */
185  isStoppingAll: boolean
186  /** Settles once the event loop has ended and its last status is written. */
187  done: Promise<void>
188  /** The session this run attached for: a /clear starts another, whose state starts empty. */
189  sessionId: string
190  checkedAt: number
191}
192
193// The running sidecar of this load of the module; a reload kills the child
194// with the module, and session.start starts another while `wanted` holds.
195let runtime: Runtime | null = null
196
197async function dataDirOf($: EngineInterface): Promise<string> {
198  const home = (await $.env.get('HOME')) ?? '/tmp'
199  return `${home}/.claude/proxy-mod`
200}
201
202async function setupFacts($: EngineInterface, options: Options): Promise<SetupFacts> {
203  const status = await read($, statusAtom)
204  return {
205    status: { ...status, port: status.phase === 'running' ? status.port : options.port },
206    dataDir: await dataDirOf($),
207    listen: options.listen,
208  }
209}
210
211async function showStatus($: EngineInterface): Promise<void> {
212  const status = await read($, statusAtom)
213  const flows = await read($, flowsAtom)
214  const isSystem = (await read($, systemProxyAtom)).isOn
215  if (status.phase === 'running') $.ui.status(`⇄ proxy :${status.port} · ${flows.length}${isSystem ? ' · system proxy' : ''}`)
216  else if (status.phase === 'starting') $.ui.status('⇄ proxy: starting…')
217  else if (status.phase === 'failed') $.ui.status('⇄ proxy: failed (/proxy)')
218  else $.ui.status(undefined)
219}
220
221function describeFatal(code: string, message: string, options: Options): string {
222  if (code === 'port-busy') {
223    return `port ${options.port} is taken (another Claude session or another proxy). Change Proxy port in /config, or stop that process: lsof -nP -iTCP:${options.port} -sTCP:LISTEN`
224  }
225  if (code === 'ca') return `could not create the certificates (openssl is needed): ${message}`
226  return message
227}
228
229function scheduleFlush($: EngineInterface, rt: Runtime, options: Options): void {
230  if (rt.isFlushScheduled) return
231  rt.isFlushScheduled = true
232  $.clock.after(200, () => {
233    rt.isFlushScheduled = false
234    void flush($, rt, options)
235  })
236}
237
238async function flush($: EngineInterface, rt: Runtime, options: Options): Promise<void> {
239  if (rt.pending.size === 0) return
240  const updates = [...rt.pending.values()]
241  rt.pending.clear()
242  await update($, flowsAtom, list => mergeFlows(list ?? [], updates, options.maxFlows))
243  const maxId = Math.max(...updates.map(flow => flow.id))
244  await update($, nextIdAtom, n => Math.max(n ?? 1, maxId + 1))
245  await showStatus($)
246}
247
248async function kill($: EngineInterface, pid: number): Promise<void> {
249  await $.process.run(['kill', String(pid)]).catch(() => undefined)
250}
251
252function isRunning(): boolean {
253  return runtime !== null
254}
255
256async function startProxy($: EngineInterface, options: Options): Promise<void> {
257  if (runtime) {
258    // running for a session a /clear ended: this one's state is empty, so attach again
259    if (!runtime.isStopping && (await $.session.id()) !== runtime.sessionId) await reattach($, options)
260    return
261  }
262  let markDone = () => {}
263  const rt: Runtime = {
264    pid: null,
265    isStopping: false,
266    pending: new Map(),
267    isFlushScheduled: false,
268    stderr: '',
269    clientPid: null,
270    isStoppingAll: false,
271    done: new Promise<void>(resolve => (markDone = resolve)),
272    sessionId: await $.session.id(),
273    checkedAt: 0,
274  }
275  runtime = rt
276
277  // A sidecar of the time before the shared proxy (Wirepane 0.7) may still hold the port; a shared one is attached to.
278  const previous = await read($, statusAtom)
279  if (previous.pid && !previous.isShared) {
280    await kill($, previous.pid)
281    await $.clock.sleep(300)
282  }
283
284  const node = await findNode($)
285  if (!node) {
286    runtime = null
287    await update($, wantedAtom, () => false)
288    await update($, statusAtom, (s): ProxyStatus => ({ ...(s ?? STOPPED), phase: 'failed', pid: null, error: NO_NODE, isNodeMissing: true }))
289    await showStatus($)
290    return
291  }
292
293  const host = options.listen === 'lan' ? '0.0.0.0' : '127.0.0.1'
294  await update($, wantedAtom, () => true)
295  await update($, statusAtom, (s): ProxyStatus => ({ ...STOPPED, lan: s?.lan ?? [], phase: 'starting', host, port: options.port }))
296  await showStatus($)
297
298  const stream = $.process.spawn({
299    argv: [
300      node,
301      `${$.plugin.root}/sidecar/attach.mjs`,
302      // this session's own
303      '--session', await $.session.id(),
304      '--project', await $.session.root(),
305      '--rules', await rulesFileOf($),
306      // the proxy's, when this session starts it
307      '--port', String(options.port),
308      '--host', host,
309      '--data', await dataDirOf($),
310      '--run', await $.session.id(),
311      '--first-id', String(await read($, nextIdAtom)),
312      '--no-decrypt', options.noDecrypt,
313      '--insecure-hosts', options.insecureHosts,
314      '--upstream-proxy', options.upstreamProxy,
315      '--upstream-bypass', options.upstreamBypass,
316      '--tracking', await trackingFileOf($),
317      '--system-proxy-backup', await systemProxyBackupOf($),
318      '--trust', await trustFileOf($),
319    ],
320  })
321
322  void (async () => {
323    let rest = ''
324    let fatal: string | null = null
325    try {
326      for await (const chunk of stream) {
327        if (chunk.stream === 'stderr') {
328          rt.stderr = (rt.stderr + chunk.text).slice(-4000)
329          continue
330        }
331        const split = splitLines(rest, chunk.text)
332        rest = split.rest
333        for (const line of split.lines) {
334          const event = parseEvent(line)
335          if (!event) continue
336          // a /clear began another session, its state empty: attach again, and the proxy tells it everything
337          if (!rt.isStopping && Date.now() - rt.checkedAt > 1000) {
338            rt.checkedAt = Date.now()
339            if ((await $.session.id()) !== rt.sessionId) {
340              void reattach($, options)
341              break
342            }
343          }
344          if (event.t === 'tick') continue
345          if (event.t === 'stopping') {
346            // stopped on purpose, by this session or another one: not a failure
347            if (!rt.isStopping) await say($, 'The proxy was stopped from another session; /proxy starts it again.')
348            rt.isStopping = true
349            await update($, wantedAtom, () => false)
350            continue
351          }
352          if (event.t === 'attached') {
353            rt.clientPid = event.pid
354            // a proxy this session started takes this session's tracked domains (restored by a --resume)
355            if (event.isStarted) await writeTrackingFile($)
356          } else if (event.t === 'ready') {
357            rt.pid = event.pid
358            if (rt.isStopping) {
359              // stopping for everyone kills the proxy; a session leaving only lets go of it
360              await kill($, rt.isStoppingAll || !rt.clientPid ? event.pid : rt.clientPid)
361              continue
362            }
363            // another proxy run than the one this list came from: its requests follow
364            const before = await read($, statusAtom)
365            if (before.runDir && before.runDir !== event.runDir) await update($, flowsAtom, () => [])
366            if (event.sessions) await update($, sessionsAtom, () => event.sessions ?? [])
367            await update($, statusAtom, (): ProxyStatus => ({
368              phase: 'running',
369              host: event.host,
370              port: event.port,
371              addresses: event.addresses,
372              lan: event.lan ?? [],
373              runDir: event.runDir,
374              control: event.control ?? null,
375              isShared: event.isShared === true,
376              proxyVersion: event.version,
377              pid: event.pid,
378              ca: event.ca,
379              error: null,
380            }))
381            await showStatus($)
382          } else if (event.t === 'rules') {
383            // the file changed on disk (an edit, a git checkout): read it again
384            await loadRules($)
385          } else if (event.t === 'system-proxy') {
386            const isOn = event.isOn
387            await update($, systemProxyAtom, (now): ProxySystemProxy => ({ ...(now ?? { service: null, isOurs: false }), isOn }))
388          } else if (event.t === 'skipped') {
389            await update($, skippedAtom, () => event.hosts)
390          } else if (event.t === 'network') {
391            // the Mac joined another network: the phone's address changed
392            await update($, statusAtom, (s): ProxyStatus => ({ ...(s ?? STOPPED), lan: event.lan }))
393          } else if (event.t === 'flow') {
394            rt.pending.set(event.flow.id, event.flow)
395            scheduleFlush($, rt, options)
396          } else if (event.t === 'sessions') {
397            await update($, sessionsAtom, () => event.sessions)
398          } else if (event.t === 'tracking') {
399            // the proxy's tracked domains, which every attached session shares
400            const next: ProxyTracking = { enabled: event.enabled, patterns: event.patterns }
401            await update($, trackingAtom, () => next)
402            await $.store.set(`tracking:${await $.session.id()}`, next).catch(() => undefined)
403          } else if (event.t === 'pinned') {
404            await update($, pinnedAtom, list => [...(list ?? []).filter(p => !(p.client === event.client && p.host === event.host)), { client: event.client, host: event.host }])
405          } else if (event.t === 'unpinned') {
406            await update($, pinnedAtom, list => (list ?? []).filter(p => !(p.client === event.client && p.host === event.host)))
407          } else if (event.t === 'held') {
408            const { t, ...held } = event
409            void t
410            await update($, heldAtom, list => [...(list ?? []).filter(h => h.id !== held.id), held])
411            $.ui.toast(`proxy: ${held.view.method} ${held.view.url.replace(/^https?:\/\//, '')} is held at a breakpoint`)
412          } else if (event.t === 'released') {
413            await update($, heldAtom, list => (list ?? []).filter(h => h.id !== event.id))
414          } else if (event.t === 'cleared') {
415            rt.pending.clear()
416            await update($, flowsAtom, () => [])
417            await showStatus($)
418          } else if (event.t === 'fatal') {
419            fatal = describeFatal(event.code, event.message, options)
420          } else if (event.t === 'log' && event.source === 'attach') {
421            // another version or other settings on the running proxy: the person should know
422            await say($, `Wirepane: ${event.message}.`)
423          } else if (event.t === 'log' && event.level === 'error') {
424            $.ui.log(`proxy: ${event.message}`, { to: 'debug' })
425          }
426        }
427      }
428    } catch (error) {
429      fatal = `could not start "${node}": ${error instanceof Error ? error.message : String(error)}. The proxy needs Node ${MIN_NODE} or newer.`
430    }
431    if (runtime === rt) runtime = null
432    // The module may be unloading as the child ends: what is left to record
433    // is best effort.
434    try {
435      await flush($, rt, options)
436      const tail = rt.stderr.trim().split('\n').slice(-3).join(' ⏎ ')
437      await update($, statusAtom, (status): ProxyStatus => ({
438        ...(status ?? STOPPED),
439        phase: rt.isStopping ? 'stopped' : 'failed',
440        pid: null,
441        error: rt.isStopping ? null : (fatal ?? `the proxy exited unexpectedly${tail ? `: ${tail}` : ''}`),
442      }))
443      if (!rt.isStopping) await update($, wantedAtom, () => false)
444      await showStatus($)
445    } catch {
446      // nothing left to tell
447    }
448    markDone()
449  })()
450}
451
452/** Lets go of the shared proxy and attaches again, for a session whose state started over; the proxy runs on. */
453async function reattach($: EngineInterface, options: Options): Promise<void> {
454  const old = runtime
455  if (old) {
456    old.isStopping = true
457    if (old.clientPid) await kill($, old.clientPid)
458    await old.done
459  }
460  await startProxy($, options)
461}
462
463/** Stops the proxy and starts it again once the old run has wound down (new settings, a new version). */
464async function restartProxy($: EngineInterface, options: Options): Promise<void> {
465  const old = runtime
466  await stopProxy($)
467  if (old) await old.done
468  await startProxy($, options)
469}
470
471/** Stops the shared proxy for every session; answers how many others used it. */
472async function stopProxy($: EngineInterface): Promise<number> {
473  const me = await $.session.id()
474  const others = (await read($, sessionsAtom)).filter(s => s.session !== me).length
475  await update($, wantedAtom, () => false)
476  await revertAndroid($)
477  // nothing may stay pointed at a proxy that is gone
478  await disableSystemProxy($, true)
479  const rt = runtime
480  const status = await read($, statusAtom)
481  const pid = rt?.pid ?? status.pid
482  if (rt) {
483    rt.isStopping = true
484    rt.isStoppingAll = true
485  }
486  // no proxy pid yet (it has not answered): letting go of it ends this run, and the proxy goes after its linger
487  if (pid) await kill($, pid)
488  else if (rt?.clientPid) await kill($, rt.clientPid)
489  if (!rt) {
490    await update($, statusAtom, (s): ProxyStatus => ({ ...(s ?? STOPPED), phase: 'stopped', pid: null, error: null }))
491    await showStatus($)
492  }
493  await update($, sessionsAtom, () => [])
494  return others
495}
496
497/** This session ends: the last one stops the proxy (the devices and the system proxy back first); the others only let go of it. */
498async function leaveProxy($: EngineInterface): Promise<void> {
499  const rt = runtime
500  if (!rt) return
501  const me = await $.session.id()
502  const others = (await read($, sessionsAtom)).filter(s => s.session !== me)
503  if (others.length === 0 || !(await read($, statusAtom)).isShared) {
504    await stopProxy($)
505    return
506  }
507  rt.isStopping = true
508  if (rt.clientPid) await kill($, rt.clientPid)
509}
510
511/** The system proxy a proxy that died left pointing at nothing goes back as it was; a running proxy keeps it. */
512async function repairLeftoverProxy($: EngineInterface): Promise<void> {
513  const backup = await readSystemProxyBackup($)
514  if (!backup) return
515  const probe = await $.process.run(['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '--noproxy', '*', '--max-time', '2', `http://127.0.0.1:${backup.port}/`])
516  if (probe.stdout.trim() === '200') return
517  await disableSystemProxy($, true)
518  $.ui.toast('Wirepane put the system proxy back: a proxy that did not shut down had left it on.')
519}
520
521async function clearFlows($: EngineInterface): Promise<void> {
522  await update($, flowsAtom, () => [])
523  await showStatus($)
524}
525
526// --- what the disk holds about one flow ----------------------------------------
527
528async function loadDetail($: EngineInterface, id: number): Promise<FlowDetail | null> {
529  const status = await read($, statusAtom)
530  const runDir = status.runDir ?? `${await dataDirOf($)}/flows/${await $.session.id()}`
531  try {
532    return JSON.parse(await $.fs.read(`${runDir}/${id}.json`)) as FlowDetail
533  } catch {
534    return null
535  }
536}
537
538type LoadedBody = { text: string | null; note: string | null }
539
540async function loadBody(
541  $: EngineInterface,
542  body: FlowDetail['req'],
543  contentType: string | null,
544): Promise<LoadedBody> {
545  if (!body) return { text: null, note: null }
546  const size = body.size
547  const notes: string[] = []
548  if (body.isTruncated) notes.push(`${body.stored} of ${size} bytes recorded`)
549  if (body.encoding && !body.isDecoded) notes.push(`could not decode ${body.encoding}`)
550  if (body.view) {
551    // gRPC and protobuf: the sidecar wrote what the wire format says, field by field
552    try {
553      const what = body.viewKind === 'grpc' ? 'gRPC messages' : 'protobuf'
554      notes.unshift(`${what} decoded without a schema: field numbers, values, nested messages`)
555      return { text: await $.fs.read(body.view), note: notes.join('; ') }
556    } catch {}
557  }
558  if (!isTextual(contentType)) {
559    return { text: null, note: [`binary body (${contentType ?? 'unknown type'}), file: ${body.file}`, ...notes].join('; ') }
560  }
561  try {
562    return { text: await $.fs.read(body.file), note: notes.length ? notes.join('; ') : null }
563  } catch (error) {
564    return { text: null, note: `could not read ${body.file}: ${error instanceof Error ? error.message : String(error)}` }
565  }
566}
567
568// --- machine-side actions -----------------------------------------------------
569
570async function say($: EngineInterface, text: string): Promise<void> {
571  await update($, noticeAtom, () => text)
572}
573
574async function findBrowsers($: EngineInterface): Promise<{ browser: Browser; path: string }[]> {
575  const home = (await $.env.get('HOME')) ?? ''
576  const found: { browser: Browser; path: string }[] = []
577  for (const browser of BROWSER_CANDIDATES) {
578    for (const dir of ['/Applications', `${home}/Applications`]) {
579      const path = `${dir}/${browser.app}`
580      if (await $.fs.exists(path).catch(() => false)) {
581        found.push({ browser, path })
582        break
583      }
584    }
585  }
586  return found
587}
588
589async function launchBrowser($: EngineInterface, options: Options, slug: string): Promise<void> {
590  const status = await read($, statusAtom)
591  if (status.phase !== 'running' || !status.ca) return say($, 'Start the proxy first.')
592  const found = (await findBrowsers($)).find(b => b.browser.slug === slug)
593  if (!found) return say($, 'That browser is not in /Applications.')
594  const facts = await setupFacts($, options)
595  const ran = await $.process.run(['open', '-na', found.path, '--args', ...browserArgs(facts, found.browser)])
596  await say(
597    $,
598    ran.exitCode === 0
599      ? `${found.browser.name} started with a profile of its own; all of its traffic goes through :${status.port}.`
600      : `Could not start ${found.browser.name}: ${ran.stderr.trim()}`,
601  )
602}
603
604async function addCaToSimulators($: EngineInterface): Promise<void> {
605  const status = await read($, statusAtom)
606  if (!status.ca) return say($, 'There is no certificate yet: start the proxy.')
607  const listed = await $.process.run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j']).catch(error => ({
608    exitCode: 1,
609    stdout: '',
610    stderr: String(error),
611  }))
612  if (listed.exitCode !== 0) return say($, `xcrun simctl is not available: ${listed.stderr.trim() || 'Xcode is needed'}`)
613  type Device = { udid: string; name: string; state: string }
614  const devices = Object.values((JSON.parse(listed.stdout) as { devices: Record<string, Device[]> }).devices)
615    .flat()
616    .filter(device => device.state === 'Booted')
617  if (devices.length === 0) return say($, 'No simulator is booted: boot one and press again.')
618  const done: string[] = []
619  const failed: string[] = []
620  for (const device of devices) {
621    const ran = await $.process.run(['xcrun', 'simctl', 'keychain', device.udid, 'add-root-cert', status.ca.path])
622    if (ran.exitCode === 0) done.push(device.name)
623    else failed.push(`${device.name}: ${ran.stderr.trim()}`)
624  }
625  await say(
626    $,
627    [done.length ? `CA added to: ${done.join(', ')}.` : '', failed.length ? `Failed: ${failed.join('; ')}` : '']
628      .filter(Boolean)
629      .join(' '),
630  )
631}
632
633const NO_NODE =
634  `Node.js ${MIN_NODE} or newer runs the proxy, and there is none on PATH or where Homebrew, Volta, nvm, fnm, mise, asdf, nodenv or MacPorts put it.`
635
636/**
637 * The Node that runs the sidecar: on PATH, or where a package or version
638 * manager keeps it, since a Claude Code started from the Dock has a short
639 * PATH. Each is asked its version; nvm's and fnm's newest come first.
640 */
641async function findNode($: EngineInterface): Promise<string | null> {
642  const home = (await $.env.get('HOME')) ?? ''
643  const managed: string[] = []
644  for (const [dir, bin] of [
645    [`${home}/.nvm/versions/node`, 'bin/node'],
646    [`${home}/Library/Application Support/fnm/node-versions`, 'installation/bin/node'],
647    [`${home}/.local/share/fnm/node-versions`, 'installation/bin/node'],
648  ] as const) {
649    const names = (await $.fs.list(dir).catch(() => [])).map(entry => entry.name)
650    managed.push(...newestNodeFirst(names).map(version => `${dir}/${version}/${bin}`))
651  }
652  const candidates = [
653    'node',
654    '/opt/homebrew/bin/node',
655    '/usr/local/bin/node',
656    `${home}/.volta/bin/node`,
657    ...managed,
658    `${home}/.local/share/mise/shims/node`,
659    `${home}/.asdf/shims/node`,
660    `${home}/.nodenv/shims/node`,
661    '/opt/local/bin/node',
662  ]
663  for (const candidate of candidates) {
664    if (candidate !== 'node' && !(await $.fs.exists(candidate).catch(() => false))) continue
665    const ran = await $.process.run([candidate, '--version']).catch(() => null)
666    if (ran?.exitCode === 0 && nodeMajor(ran.stdout) >= MIN_NODE) return candidate
667  }
668  return null
669}
670
671async function findBrew($: EngineInterface): Promise<string | null> {
672  for (const path of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
673    if (await $.fs.exists(path).catch(() => false)) return path
674  }
675  return null
676}
677
678/** Installs Node with Homebrew and starts the proxy on it; without Homebrew, opens the download page. */
679async function installNode($: EngineInterface, options: Options): Promise<void> {
680  const brew = await findBrew($)
681  if (!brew) {
682    await $.process.run(['open', NODE_DOWNLOAD]).catch(() => null)
683    return
684  }
685  await update($, busyAtom, () => 'Installing Node.js: brew install node…')
686  let ran: { exitCode: number | null; stdout: string; stderr: string }
687  try {
688    ran = await $.process.run([brew, 'install', 'node'], { timeoutMs: 600_000 }).catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
689  } finally {
690    await update($, busyAtom, () => '')
691  }
692  if (ran.exitCode !== 0) {
693    const why = (ran.stderr || ran.stdout).trim().split('\n').slice(-2).join(' ⏎ ')
694    await update($, statusAtom, (s): ProxyStatus => ({ ...(s ?? STOPPED), error: `brew install node failed${why ? `: ${why}` : ''}`, isNodeMissing: true }))
695    return
696  }
697  await startProxy($, options)
698}
699
700async function adb($: EngineInterface): Promise<string | null> {
701  const onPath = await $.process.run(['adb', 'version']).catch(() => null)
702  if (onPath?.exitCode === 0) return 'adb'
703  const home = (await $.env.get('HOME')) ?? ''
704  const sdk = `${home}/Library/Android/sdk/platform-tools/adb`
705  return (await $.fs.exists(sdk).catch(() => false)) ? sdk : null
706}
707
708async function androidDevices($: EngineInterface, tool: string): Promise<string[]> {
709  const ran = await $.process.run([tool, 'devices'])
710  return ran.stdout
711    .split('\n')
712    .slice(1)
713    .map(line => line.trim().split(/\s+/))
714    .filter(parts => parts[1] === 'device')
715    .map(parts => parts[0]!)
716}
717
718/**
719 * Emulators reach this Mac's localhost as 10.0.2.2; a USB device reaches it
720 * through `adb reverse`, so neither needs the LAN.
721 */
722async function pointAndroid($: EngineInterface): Promise<void> {
723  const status = await read($, statusAtom)
724  if (status.phase !== 'running') return say($, 'Start the proxy first.')
725  const tool = await adb($)
726  if (!tool) return say($, 'adb not found (neither on PATH nor in ~/Library/Android/sdk/platform-tools).')
727  const serials = await androidDevices($, tool)
728  if (serials.length === 0) return say($, 'No emulator or device is connected (adb devices lists none).')
729  const done: string[] = []
730  for (const serial of serials) {
731    const isEmulator = serial.startsWith('emulator-')
732    if (!isEmulator) await $.process.run([tool, '-s', serial, 'reverse', `tcp:${status.port}`, `tcp:${status.port}`])
733    const target = isEmulator ? `10.0.2.2:${status.port}` : `127.0.0.1:${status.port}`
734    const ran = await $.process.run([tool, '-s', serial, 'shell', 'settings', 'put', 'global', 'http_proxy', target])
735    if (ran.exitCode === 0) done.push(serial)
736  }
737  const pointed = await update($, emulatorsAtom, list => [...new Set([...(list ?? []), ...done])])
738  await recordAndroid($, tool, status.port, pointed)
739  await say($, done.length ? `Through the proxy now: ${done.join(', ')}. "Revert" or stopping the proxy points them back.` : 'Could not set the proxy.')
740}
741
742async function revertAndroid($: EngineInterface): Promise<void> {
743  const serials = await read($, emulatorsAtom)
744  if (serials.length === 0) return
745  const tool = await adb($)
746  const status = await read($, statusAtom)
747  if (tool) {
748    for (const serial of serials) {
749      await $.process.run([tool, '-s', serial, 'shell', 'settings', 'put', 'global', 'http_proxy', ':0']).catch(() => null)
750      if (!serial.startsWith('emulator-')) {
751        await $.process.run([tool, '-s', serial, 'reverse', '--remove', `tcp:${status.port}`]).catch(() => null)
752      }
753    }
754  }
755  await update($, emulatorsAtom, () => [])
756  await recordAndroid($, tool ?? 'adb', status.port, [])
757  await say($, `Android proxy reverted on: ${serials.join(', ')}.`)
758}
759
760/** Which devices point at the proxy, on disk: a proxy whose last session vanished points them back itself. */
761async function recordAndroid($: EngineInterface, tool: string, port: number, serials: readonly string[]): Promise<void> {
762  await $.fs.write(`${await dataDirOf($)}/android-proxied.json`, serials.length ? JSON.stringify({ adb: tool, port, serials }) : '').catch(() => undefined)
763}
764
765async function openCaPageOnAndroid($: EngineInterface): Promise<void> {
766  const tool = await adb($)
767  if (!tool) return say($, 'adb not found.')
768  const serials = await androidDevices($, tool)
769  if (serials.length === 0) return say($, 'No emulator or device is connected.')
770  for (const serial of serials) {
771    await $.process.run([
772      tool, '-s', serial, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'http://claude.proxy/',
773    ])
774  }
775  await say($, `Opened the CA page on ${serials.join(', ')} (the device must be using the proxy).`)
776}
777
778// --- the rules file ------------------------------------------------------------
779//
780// <project>/.claude/proxy-rules.json, which the sidecar applies (see
781// shared/rules.mjs). A rule holding a script runs only once the script's
782// SHA-256 is in <data>/trusted-scripts.json: a cloned project's rules file
783// must not run code on this machine unasked.
784
785async function rulesFileOf($: EngineInterface): Promise<string> {
786  return `${await $.session.root()}/.claude/proxy-rules.json`
787}
788
789async function trustFileOf($: EngineInterface): Promise<string> {
790  return `${await dataDirOf($)}/trusted-scripts.json`
791}
792
793async function sha256(text: string): Promise<string> {
794  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
795  return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
796}
797
798async function readTrusted($: EngineInterface): Promise<Set<string>> {
799  try {
800    const data = JSON.parse(await $.fs.read(await trustFileOf($))) as { sha256?: unknown }
801    return new Set(Array.isArray(data.sha256) ? data.sha256.filter((hash): hash is string => typeof hash === 'string') : [])
802  } catch {
803    return new Set()
804  }
805}
806
807async function trustScripts($: EngineInterface, codes: readonly string[]): Promise<void> {
808  if (codes.length === 0) return
809  const trusted = await readTrusted($)
810  for (const code of codes) trusted.add(await sha256(code))
811  await $.fs.write(await trustFileOf($), `${JSON.stringify({ sha256: [...trusted] }, null, 2)}\n`)
812}
813
814async function readRulesText($: EngineInterface): Promise<{ file: string; text: string | null }> {
815  const file = await rulesFileOf($)
816  try {
817    return { file, text: await $.fs.read(file) }
818  } catch {
819    return { file, text: null }
820  }
821}
822
823/** Reads the rules file into the rules view's state, and answers it. */
824async function loadRules($: EngineInterface): Promise<ProxyRules> {
825  const { file, text } = await readRulesText($)
826  const parsed = parseRules(text)
827  const trusted = await readTrusted($)
828  const entries: ProxyRuleEntry[] = []
829  for (const { rule, errors } of parsed.rules) {
830    let isUntrusted = false
831    if (errors.length === 0) {
832      for (const code of scriptsOf(rule)) if (!trusted.has(await sha256(code))) isUntrusted = true
833    }
834    entries.push({
835      id: typeof rule?.id === 'string' ? rule.id : '?',
836      name: typeof rule?.name === 'string' ? rule.name : null,
837      description: typeof rule?.description === 'string' ? rule.description : null,
838      enabled: rule?.enabled !== false,
839      summary: errors.length ? '' : describeRule(rule),
840      errors,
841      isUntrusted,
842    })
843  }
844  const value: ProxyRules = { file, entries, fileErrors: parsed.errors }
845  await update($, rulesAtom, () => value)
846  return value
847}
848
849/**
850 * Rewrites the rules file's list through `change`, keeping the rest of the
851 * file; `change` answers the new list, or a string saying why not.
852 * Answers that reason, or null once written.
853 */
854async function editRules($: EngineInterface, change: (rules: Rule[]) => Rule[] | string): Promise<string | null> {
855  const { file, text } = await readRulesText($)
856  let data: { rules: Rule[] } & Record<string, unknown> = { rules: [] }
857  if (text !== null && text.trim() !== '') {
858    try {
859      data = JSON.parse(text) as typeof data
860    } catch {
861      return `${file} is not valid JSON; fix it by hand first`
862    }
863    if (data === null || typeof data !== 'object' || !Array.isArray(data.rules)) return `${file} must hold {"rules": [...]}`
864  }
865  const next = change([...data.rules])
866  if (typeof next === 'string') return next
867  await $.fs.write(file, `${JSON.stringify({ ...data, rules: next }, null, 2)}\n`)
868  await loadRules($)
869  return null
870}
871
872async function toggleRule($: EngineInterface, id: string): Promise<void> {
873  const failure = await editRules($, rules => rules.map(rule => (rule.id === id ? { ...rule, enabled: rule.enabled === false } : rule)))
874  if (failure) await say($, failure)
875}
876
877async function moveRule($: EngineInterface, id: string, by: number): Promise<void> {
878  const failure = await editRules($, rules => {
879    const from = rules.findIndex(rule => rule.id === id)
880    const to = Math.max(0, Math.min(rules.length - 1, from + by))
881    if (from < 0 || from === to) return rules
882    const [moved] = rules.splice(from, 1)
883    rules.splice(to, 0, moved!)
884    return rules
885  })
886  if (failure) await say($, failure)
887}
888
889/** Takes the rule out of the file; answers why not, or null once removed. */
890async function removeRule($: EngineInterface, id: string): Promise<string | null> {
891  return editRules($, rules => (rules.some(rule => rule.id === id) ? rules.filter(rule => rule.id !== id) : `No rule has the id ${id}.`))
892}
893
894/** The rules view's ✕ asks once more; this is its answer. */
895async function confirmRemoveRule($: EngineInterface, id: string): Promise<void> {
896  await update($, viewAtom, (v): ProxyView => ({ ...v, removing: null }))
897  const failure = await removeRule($, id)
898  await say($, failure ?? `Removed ${id}.`)
899}
900
901async function allowRuleScripts($: EngineInterface, id: string): Promise<void> {
902  const { text } = await readRulesText($)
903  const rule = parseRules(text).rules.find(entry => entry.rule?.id === id)?.rule
904  if (!rule) return
905  await trustScripts($, scriptsOf(rule))
906  await loadRules($)
907  await say($, `Scripts of ${id} approved; the proxy runs them from now on.`)
908}
909
910// --- the macOS system proxy -------------------------------------------------------------
911//
912// Turned on for the network service the default route leaves by, with Claude's
913// hosts on its bypass list; what was there before is kept in a backup file,
914// put back on stop, at the session's end, or by the sidecar if Claude Code
915// dies. networksetup may want an administrator: then one macOS password dialog.
916
917async function systemProxyBackupOf($: EngineInterface): Promise<string> {
918  return `${await dataDirOf($)}/system-proxy-backup.json`
919}
920
921async function readSystemProxyBackup($: EngineInterface): Promise<SystemProxyBackup | null> {
922  try {
923    const text = await $.fs.read(await systemProxyBackupOf($))
924    return text.trim() ? (JSON.parse(text) as SystemProxyBackup) : null
925  } catch {
926    return null
927  }
928}
929
930/** Runs networksetup commands, as an administrator when it must; answers why not, or null. */
931async function runNetworkCommands($: EngineInterface, commands: string[][]): Promise<string | null> {
932  for (const argv of commands) {
933    const ran = await $.process.run(argv).catch(error => ({ exitCode: 1, stdout: '', stderr: String(error) }))
934    const output = `${ran.stdout}${ran.stderr}`
935    if (ran.exitCode === 0 && !/\*\* Error/i.test(output)) continue
936    if (!needsAdmin(output) && !/\*\* Error/i.test(output)) return output.trim() || `${argv[0]} failed`
937    const admin = await $.process.run(['osascript', '-e', asAdminScript(commands)], { timeoutMs: 180_000 })
938    return admin.exitCode === 0 ? null : admin.stderr.includes('-128') ? 'cancelled' : admin.stderr.trim() || 'the administrator dialog failed'
939  }
940  return null
941}
942
943async function defaultNetworkService($: EngineInterface): Promise<string | null> {
944  const route = await $.process.run(['route', '-n', 'get', 'default']).catch(() => null)
945  const device = /interface:\s*(\S+)/.exec(route?.stdout ?? '')?.[1]
946  const order = parseServiceOrder((await $.process.run(['networksetup', '-listnetworkserviceorder'])).stdout)
947  return order.find(service => service.device === device && !service.isDisabled)?.name ?? order.find(service => service.name === 'Wi-Fi')?.name ?? null
948}
949
950async function enableSystemProxy($: EngineInterface, options: Options): Promise<void> {
951  const status = await read($, statusAtom)
952  if (status.phase !== 'running') await startProxy($, options)
953  const port = status.phase === 'running' ? status.port : options.port
954  const service = await defaultNetworkService($)
955  if (!service) return say($, 'Could not tell which network service this Mac uses.')
956  const kept = await readSystemProxyBackup($)
957  // a backup already there is the state before we first turned it on: keep that one
958  const backup: SystemProxyBackup = kept ?? {
959    service,
960    port,
961    previous: {
962      web: parseProxyState((await $.process.run(['networksetup', '-getwebproxy', service])).stdout),
963      secure: parseProxyState((await $.process.run(['networksetup', '-getsecurewebproxy', service])).stdout),
964      bypass: parseBypass((await $.process.run(['networksetup', '-getproxybypassdomains', service])).stdout),
965    },
966  }
967  await $.fs.write(await systemProxyBackupOf($), JSON.stringify(backup, null, 2))
968  await update($, busyAtom, () => `Pointing ${service} at the proxy…`)
969  const failure = await runNetworkCommands($, enableCommands(service, port, backup.previous.bypass))
970  await update($, busyAtom, () => '')
971  if (failure) {
972    if (!kept) await $.fs.write(await systemProxyBackupOf($), '')
973    return say($, `The system proxy is unchanged: ${failure}.`)
974  }
975  await update($, systemProxyAtom, (): ProxySystemProxy => ({ isOn: true, service, isOurs: true }))
976  await showStatus($)
977  await say($, `This Mac's ${service} now goes through the proxy. Claude's own traffic bypasses it; it goes back when the proxy stops.`)
978}
979
980async function disableSystemProxy($: EngineInterface, quiet = false): Promise<void> {
981  const backup = await readSystemProxyBackup($)
982  if (!backup) {
983    if (!quiet) await say($, 'The system proxy was not turned on from here; switch it off in System Settings → Network → Details → Proxies.')
984    return
985  }
986  const failure = await runNetworkCommands($, restoreCommands(backup))
987  if (failure) {
988    if (!quiet) await say($, `Could not put the system proxy back: ${failure}.`)
989    return
990  }
991  await $.fs.write(await systemProxyBackupOf($), '')
992  await update($, systemProxyAtom, (): ProxySystemProxy => ({ isOn: false, service: backup.service, isOurs: false }))
993  await showStatus($)
994  if (!quiet) await say($, `${backup.service} is back to its own proxy settings.`)
995}
996
997// --- this Mac's trust in the CA ------------------------------------------------------------
998//
999// Safari and native apps behind the system proxy need the CA in the keychain;
1000// the separate browser and the simulators do not.
1001
1002async function checkMacTrust($: EngineInterface): Promise<void> {
1003  const ca = (await read($, statusAtom)).ca
1004  if (!ca) return
1005  const ran = await $.process.run(['security', 'verify-cert', '-c', ca.path]).catch(() => null)
1006  const output = `${ran?.stdout ?? ''}${ran?.stderr ?? ''}`
1007  await update($, macTrustAtom, () => (/successful/i.test(output) ? 'trusted' : /NOT_TRUSTED|failed/i.test(output) ? 'untrusted' : 'unknown'))
1008}
1009
1010async function trustCaOnMac($: EngineInterface): Promise<void> {
1011  const ca = (await read($, statusAtom)).ca
1012  if (!ca) return say($, 'There is no certificate yet: start the proxy.')
1013  const home = (await $.env.get('HOME')) ?? ''
1014  await update($, busyAtom, () => 'Waiting for you to confirm in the macOS dialog…')
1015  try {
1016    const ran = await $.process.run(['security', 'add-trusted-cert', '-r', 'trustRoot', '-k', `${home}/Library/Keychains/login.keychain-db`, ca.path], {
1017      timeoutMs: 180_000,
1018    })
1019    await checkMacTrust($)
1020    await say($, ran.exitCode === 0 ? 'This Mac trusts the proxy CA now (login keychain).' : `The keychain refused: ${ran.stderr.trim() || 'cancelled'}`)
1021  } finally {
1022    await update($, busyAtom, () => '')
1023  }
1024}
1025
1026// --- simulators, emulators, devices ------------------------------------------------------
1027
1028function runtimeLabel(key: string): string {
1029  // com.apple.CoreSimulator.SimRuntime.iOS-18-2 → iOS 18.2
1030  const tail = key.split('.').pop() ?? key
1031  return tail.replace(/-(\d+)-(\d+)$/, ' $1.$2').replace(/-/g, ' ')
1032}
1033
1034function runtimeRank(runtime: string): number {
1035  const match = /(\d+)\.(\d+)/.exec(runtime)
1036  return match ? Number(match[1]) * 100 + Number(match[2]) : 0
1037}
1038
1039async function scanSimulators($: EngineInterface): Promise<void> {
1040  const ran = await $.process.run(['xcrun', 'simctl', 'list', 'devices', 'available', '-j'], { timeoutMs: 30_000 }).catch(error => ({
1041    exitCode: 1,
1042    stdout: '',
1043    stderr: String(error),
1044  }))
1045  if (ran.exitCode !== 0) {
1046    await update($, devicesAtom, (d): ProxyDevices => ({ ...d!, simulators: [], simulatorError: 'Xcode’s simctl is not available: install Xcode to use simulators.' }))
1047    return
1048  }
1049  type Listed = { udid: string; name: string; state: string; isAvailable?: boolean }
1050  const devices = (JSON.parse(ran.stdout) as { devices: Record<string, Listed[]> }).devices
1051  const simulators: ProxySimulator[] = []
1052  for (const [runtime, list] of Object.entries(devices)) {
1053    if (!/iOS/.test(runtime)) continue
1054    for (const device of list) simulators.push({ udid: device.udid, name: device.name, runtime: runtimeLabel(runtime), state: device.state })
1055  }
1056  simulators.sort(
1057    (a, b) =>
1058      Number(b.state === 'Booted') - Number(a.state === 'Booted') ||
1059      runtimeRank(b.runtime) - runtimeRank(a.runtime) ||
1060      Number(b.name.startsWith('iPhone')) - Number(a.name.startsWith('iPhone')) ||
1061      a.name.localeCompare(b.name),
1062  )
1063  await update($, devicesAtom, (d): ProxyDevices => ({ ...d!, simulators, simulatorError: null }))
1064}
1065
1066async function androidTool($: EngineInterface, name: 'adb' | 'emulator'): Promise<string | null> {
1067  const onPath = await $.process.run([name, name === 'adb' ? 'version' : '-version']).catch(() => null)
1068  if (onPath?.exitCode === 0) return name
1069  const home = (await $.env.get('HOME')) ?? ''
1070  const sdk = (await $.env.get('ANDROID_HOME')) ?? `${home}/Library/Android/sdk`
1071  const path = name === 'adb' ? `${sdk}/platform-tools/adb` : `${sdk}/emulator/emulator`
1072  return (await $.fs.exists(path).catch(() => false)) ? path : null
1073}
1074
1075async function scanAndroid($: EngineInterface): Promise<void> {
1076  const emulator = await androidTool($, 'emulator')
1077  const tool = await adb($)
1078  if (!emulator && !tool) {
1079    await update($, devicesAtom, (d): ProxyDevices => ({ ...d!, avds: [], android: [], androidError: 'The Android SDK was not found (looked on PATH and in ~/Library/Android/sdk).' }))
1080    return
1081  }
1082  const avds = emulator
1083    ? (await $.process.run([emulator, '-list-avds'])).stdout.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('INFO'))
1084    : []
1085  const android: ProxyAndroidDevice[] = []
1086  if (tool) {
1087    for (const serial of await androidDevices($, tool)) {
1088      const isEmulator = serial.startsWith('emulator-')
1089      const avd = isEmulator ? (await $.process.run([tool, '-s', serial, 'emu', 'avd', 'name'])).stdout.split('\n')[0]?.trim() || null : null
1090      android.push({ serial, avd, isEmulator })
1091    }
1092  }
1093  await update($, devicesAtom, (d): ProxyDevices => ({ ...d!, avds, android, androidError: null }))
1094}
1095
1096async function installCaOnSimulator($: EngineInterface, udid: string): Promise<boolean> {
1097  const status = await read($, statusAtom)
1098  if (!status.ca) {
1099    await say($, 'There is no certificate yet: start the proxy.')
1100    return false
1101  }
1102  const ran = await $.process.run(['xcrun', 'simctl', 'keychain', udid, 'add-root-cert', status.ca.path], { timeoutMs: 60_000 })
1103  if (ran.exitCode !== 0) {
1104    await say($, `Could not add the CA: ${ran.stderr.trim() || 'simctl failed'}`)
1105    return false
1106  }
1107  await update($, caSimulatorsAtom, list => [...new Set([...(list ?? []), udid])])
1108  return true
1109}
1110
1111/** Boots the simulator if needed, puts the CA in, points this Mac at the proxy, brings Simulator up. */
1112async function useSimulator($: EngineInterface, udid: string, options: Options): Promise<void> {
1113  const simulator = (await read($, devicesAtom)).simulators.find(sim => sim.udid === udid)
1114  const name = simulator?.name ?? 'the simulator'
1115  try {
1116    if ((await read($, statusAtom)).phase !== 'running') await startProxy($, options)
1117    if (simulator?.state !== 'Booted') {
1118      await update($, busyAtom, () => `Booting ${name}…`)
1119      await $.process.run(['xcrun', 'simctl', 'boot', udid], { timeoutMs: 120_000 })
1120    }
1121    await $.process.run(['open', '-a', 'Simulator', '--args', '-CurrentDeviceUDID', udid])
1122    await update($, busyAtom, () => `Waiting for ${name} to finish booting…`)
1123    await $.process.run(['xcrun', 'simctl', 'bootstatus', udid, '-b'], { timeoutMs: 240_000 })
1124    await update($, busyAtom, () => `Adding the CA to ${name}…`)
1125    if (!(await installCaOnSimulator($, udid))) return
1126    if (!(await read($, systemProxyAtom)).isOn) await enableSystemProxy($, options)
1127    await scanSimulators($)
1128    if ((await read($, systemProxyAtom)).isOn) await say($, `${name} is ready: the CA is in, and its traffic goes through the proxy.`)
1129  } finally {
1130    await update($, busyAtom, () => '')
1131  }
1132}
1133
1134async function startAvd($: EngineInterface, avd: string, options: Options): Promise<void> {
1135  const emulator = await androidTool($, 'emulator')
1136  if (!emulator) return say($, 'The Android emulator was not found.')
1137  if ((await read($, statusAtom)).phase !== 'running') await startProxy($, options)
1138  const port = (await read($, statusAtom)).port || options.port
1139  const quote = (text: string) => `'${text.replace(/'/g, `'\\''`)}'`
1140  // the emulator outlives this call: started in the background, its traffic sent to the proxy from boot
1141  await $.process.run(['/bin/sh', '-c', `nohup ${quote(emulator)} -avd ${quote(avd)} -http-proxy http://127.0.0.1:${port} >/dev/null 2>&1 &`])
1142  await say($, `${avd} is starting with its traffic going through the proxy from boot.`)
1143  // once it is up, its browser opens the CA page: one tap installs the certificate
1144  const tool = await adb($)
1145  if (!tool) return
1146  try {
1147    await update($, busyAtom, () => `Waiting for ${avd} to boot…`)
1148    const adbq = quote(tool)
1149    const found = await $.process.run(
1150      [
1151        '/bin/sh',
1152        '-c',
1153        `for i in $(seq 1 90); do for s in $(${adbq} devices | awk '/^emulator-/{print $1}'); do ` +
1154          `n=$(${adbq} -s "$s" emu avd name 2>/dev/null | head -1 | tr -d '\\r'); [ "$n" = ${quote(avd)} ] && echo "$s" && exit 0; done; sleep 2; done; exit 1`,
1155      ],
1156      { timeoutMs: 200_000 },
1157    )
1158    const serial = found.stdout.trim()
1159    if (found.exitCode !== 0 || !serial) return say($, `${avd} did not come up in time; when it does, press Open CA page.`)
1160    await $.process.run([tool, '-s', serial, 'shell', 'while [ "$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done'], { timeoutMs: 240_000 })
1161    await $.process.run([tool, '-s', serial, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'http://claude.proxy/'])
1162    await scanAndroid($)
1163    await say($, `${avd} is up behind the proxy, and its browser shows the CA page: download the certificate, then install it in Settings → Security → Encryption & credentials.`)
1164  } finally {
1165    await update($, busyAtom, () => '')
1166  }
1167}
1168
1169// --- tracked domains ------------------------------------------------------------------
1170//
1171// The proxy's: one file it watches, shared by every attached session; each
1172// session keeps its last list in $.store under its id too, so a --resume
1173// that starts the proxy brings it back.
1174
1175async function trackingFileOf($: EngineInterface): Promise<string> {
1176  return `${await dataDirOf($)}/tracking.json`
1177}
1178
1179/** Writes the session's list where the proxy reads it; answers the path. */
1180async function writeTrackingFile($: EngineInterface): Promise<string> {
1181  const file = await trackingFileOf($)
1182  const tracking = await read($, trackingAtom)
1183  await $.fs.write(file, `${JSON.stringify(tracking, null, 2)}\n`)
1184  return file
1185}
1186
1187async function setTracking($: EngineInterface, change: (now: ProxyTracking) => ProxyTracking): Promise<ProxyTracking> {
1188  const next = await update($, trackingAtom, now => {
1189    const changed = change(now ?? { enabled: false, patterns: [] })
1190    return { enabled: changed.enabled, patterns: [...new Set(changed.patterns)] }
1191  })
1192  await $.store.set(`tracking:${await $.session.id()}`, next).catch(() => undefined)
1193  await writeTrackingFile($)
1194  return next
1195}
1196
1197async function restoreTracking($: EngineInterface): Promise<void> {
1198  const saved = (await $.store.get(`tracking:${await $.session.id()}`).catch(() => undefined)) as ProxyTracking | undefined
1199  if (saved && typeof saved.enabled === 'boolean' && Array.isArray(saved.patterns)) {
1200    await update($, trackingAtom, () => ({ enabled: saved.enabled, patterns: saved.patterns.filter(p => typeof p === 'string') }))
shared/rules.mjs 531 lines
1// The rules engine's shared half: what a rules file holds, whether a rule
2// matches a request or a response, and how a rule reads as text. Plain
3// JavaScript with no Node API, so the sidecar (which applies rules) and the
4// mod (which lists and edits them) read a file the same way.
5//
6// A rules file is `{ "rules": [Rule, ...] }`; its order is the priority, the
7// first rule applying first. A rule:
8//   { id, name?, description?, enabled?, match?, request?: Action[],
9//     response?: Action[], messages?: Step[], stop? }
10// `messages` acts on a WebSocket's messages; `match` then matches the
11// upgrade request, and `request` steps act on it (a `respond` with status
12// 101 makes Wirepane the WebSocket server).
13
14export const REQUEST_ACTIONS = [
15  'delay', 'throttle', 'setHeader', 'removeHeader', 'setQuery', 'removeQuery',
16  'mapRemote', 'replaceUrl', 'setBody', 'replaceBody', 'mergeJson', 'respond', 'fail', 'script', 'breakpoint',
17]
18export const RESPONSE_ACTIONS = [
19  'delay', 'throttle', 'setStatus', 'setHeader', 'removeHeader',
20  'setBody', 'replaceBody', 'mergeJson', 'fail', 'script', 'breakpoint',
21]
22export const MESSAGE_ACTIONS = ['replaceMessage', 'setMessage', 'mergeJson', 'drop', 'delay', 'reply', 'send', 'close', 'script']
23// what a step may do as a WebSocket opens, before any message
24const OPEN_ACTIONS = new Set(['send', 'close', 'delay', 'script'])
25// a breakpoint shows the whole body, and may change it
26const BODY_ACTIONS = new Set(['setBody', 'replaceBody', 'mergeJson', 'script', 'breakpoint'])
27const MATCH_KEYS = ['url', 'host', 'path', 'methods', 'headers', 'query', 'bodyContains', 'status', 'contentType']
28const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
29
30// --- patterns ---------------------------------------------------------------
31//
32// A pattern is a glob (`*` any run of characters, `?` one) matched whole and
33// without regard to case, or a regular expression written `re:<source>`. (Not
34// `/source/flags`: every path starts with a slash, and `/v1/login` would read
35// as the expression `v1` with the flags `login`.)
36
37export function isRegexPattern(pattern) {
38  return typeof pattern === 'string' && pattern.startsWith('re:') && pattern.length > 3
39}
40
41const compiled = new Map()
42
43export function toRegExp(pattern) {
44  let regex = compiled.get(pattern)
45  if (!regex) {
46    regex = compile(pattern)
47    if (compiled.size > 500) compiled.clear()
48    compiled.set(pattern, regex)
49  }
50  return regex
51}
52
53function compile(pattern) {
54  if (isRegexPattern(pattern)) return new RegExp(pattern.slice(3))
55  const source = String(pattern)
56    .replace(/[.+^${}()|[\]\\]/g, '\\$&')
57    .replace(/\*/g, '.*')
58    .replace(/\?/g, '.')
59  return new RegExp(`^${source}$`, 'i')
60}
61
62function patternError(pattern, where) {
63  if (typeof pattern !== 'string' || pattern === '') return `${where} must be a non-empty string`
64  try {
65    toRegExp(pattern)
66    return null
67  } catch (error) {
68    return `${where}: ${error.message}`
69  }
70}
71
72function matchesPattern(pattern, value) {
73  return toRegExp(pattern).test(value)
74}
75
76function matchesHost(pattern, host) {
77  // *.example.com covers example.com itself too
78  if (!isRegexPattern(pattern) && pattern.startsWith('*.') && host.toLowerCase() === pattern.slice(2).toLowerCase()) return true
79  return matchesPattern(pattern, host)
80}
81
82/** `404`, `4xx`, `>=400`, `<300`, `500-599`. */
83export function statusMatcher(text) {
84  const value = String(text).trim().toLowerCase()
85  if (/^[1-5]xx$/.test(value)) {
86    const base = Number(value[0]) * 100
87    return status => status >= base && status < base + 100
88  }
89  if (/^\d{3}$/.test(value)) return status => status === Number(value)
90  const range = /^(\d{3})-(\d{3})$/.exec(value)
91  if (range) return status => status >= Number(range[1]) && status <= Number(range[2])
92  const compare = /^(>=|<=|>|<)(\d{3})$/.exec(value)
93  if (compare) {
94    const n = Number(compare[2])
95    const op = compare[1]
96    return status => (op === '>=' ? status >= n : op === '<=' ? status <= n : op === '>' ? status > n : status < n)
97  }
98  return null
99}
100
101// --- tracked domains ------------------------------------------------------------
102//
103// A session may track some domains only: those are decrypted and recorded,
104// everything else passes through untouched. A host pattern is a glob over the
105// host name (`*.example.com` covers example.com too) or `re:<source>`.
106
107/**
108 * A host pattern from what a person types: a URL, `host:port` or a bare
109 * name all come to the host part, lower-cased. Null when nothing is left.
110 */
111export function normalizeHostPattern(text) {
112  let value = String(text ?? '').trim()
113  if (value === '') return null
114  if (isRegexPattern(value)) return value
115  value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/?#]/)[0].toLowerCase()
116  value = value.replace(/:\d+$/, '').replace(/\.$/, '')
117  return /^[a-z0-9*?._-]+$/.test(value) && /[a-z0-9*?]/.test(value) ? value : null
118}
119
120export function matchesHostPattern(pattern, host) {
121  try {
122    return matchesHost(pattern, String(host).toLowerCase())
123  } catch {
124    return false
125  }
126}
127
128/** Whether a host is tracked: everything is, while the list is off or empty. */
129export function isTracked(tracking, host) {
130  if (!tracking || !tracking.enabled || !Array.isArray(tracking.patterns) || tracking.patterns.length === 0) return true
131  return tracking.patterns.some(pattern => matchesHostPattern(pattern, host))
132}
133
134/** `*.example.com` for api.example.com: the wildcard a skipped host suggests. */
135export function wildcardFor(host) {
136  const labels = String(host).toLowerCase().split('.')
137  if (labels.length < 3 || /^\d+$/.test(labels.at(-1))) return null
138  // two-label public suffixes (co.uk, com.au, ...) keep three labels
139  const keep = labels.at(-2).length <= 3 && labels.at(-1).length === 2 && labels.length >= 4 ? 3 : 2
140  return `*.${labels.slice(-keep).join('.')}`
141}
142
143// --- validation ---------------------------------------------------------------
144
145const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
146const isString = value => typeof value === 'string'
147const isNumber = (value, min, max) => typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
148
149function bodySourceError(action, where) {
150  const given = ['text', 'json', 'file'].filter(key => action[key] !== undefined)
151  if (given.length !== 1) return `${where}: give exactly one of text, json, file`
152  if (action.text !== undefined && !isString(action.text)) return `${where}: text must be a string`
153  if (action.file !== undefined && (!isString(action.file) || action.file === '')) return `${where}: file must be a path`
154  return null
155}
156
157function actionErrors(action, phase, where) {
158  if (!isObject(action)) return [`${where} must be an object`]
159  const allowed = phase === 'request' ? REQUEST_ACTIONS : phase === 'response' ? RESPONSE_ACTIONS : MESSAGE_ACTIONS
160  if (!allowed.includes(action.type)) {
161    return [`${where}: type must be one of ${allowed.join(', ')} in ${phase} (got ${JSON.stringify(action.type)})`]
162  }
163  const errors = []
164  const need = (ok, text) => {
165    if (!ok) errors.push(`${where} (${action.type}): ${text}`)
166  }
167  if (phase === 'messages') {
168    if (action.direction !== undefined) need(['out', 'in', 'both'].includes(action.direction), 'direction must be out (client to server), in (server to client) or both')
169    if (action.on !== undefined) need(action.on === 'message' || action.on === 'open', 'on must be message or open')
170    if (action.on === 'open') {
171      need(OPEN_ACTIONS.has(action.type), `on open takes ${[...OPEN_ACTIONS].join(', ')} only`)
172      need(action.when === undefined && action.direction === undefined, 'on open takes no when or direction')
173    }
174    if (action.when !== undefined) {
175      if (!isString(action.when) || action.when === '') need(false, 'when must be text the message holds, or re:<regex>')
176      else if (isRegexPattern(action.when)) {
177        const error = patternError(action.when, `${where} (${action.type}) when`)
178        if (error) errors.push(error)
179      }
180    }
181  }
182  switch (action.type) {
183    case 'delay':
184      need(isNumber(action.ms, 0, 600_000), 'ms must be a number from 0 to 600000')
185      if (action.msMax !== undefined) need(isNumber(action.msMax, action.ms ?? 0, 600_000), 'msMax must be at least ms and at most 600000')
186      break
187    case 'throttle':
188      need(isNumber(action.bytesPerSecond, 1, 1e10), 'bytesPerSecond must be a positive number')
189      break
190    case 'setHeader':
191    case 'setQuery':
192      need(isString(action.name) && action.name !== '', 'name must be a non-empty string')
193      need(isString(action.value), 'value must be a string')
194      break
195    case 'removeHeader':
196    case 'removeQuery':
197      need(isString(action.name) && action.name !== '', 'name must be a non-empty string')
198      break
199    case 'mapRemote':
200      need(['scheme', 'host', 'port', 'path'].some(key => action[key] !== undefined), 'give at least one of scheme, host, port, path')
201      if (action.scheme !== undefined) need(action.scheme === 'http' || action.scheme === 'https', 'scheme must be http or https')
202      if (action.host !== undefined) need(isString(action.host) && action.host !== '', 'host must be a non-empty string')
203      if (action.port !== undefined) need(isNumber(action.port, 1, 65535) && Number.isInteger(action.port), 'port must be 1 to 65535')
204      if (action.path !== undefined) need(isString(action.path) && action.path.startsWith('/'), 'path must start with /')
205      break
206    case 'replaceUrl':
207    case 'replaceMessage':
208    case 'replaceBody': {
209      const error = patternError(action.pattern, `${where} (${action.type}) pattern`)
210      if (error) errors.push(error)
211      need(isString(action.with), 'with must be a string')
212      break
213    }
214    case 'setBody':
215    case 'setMessage':
216    case 'reply': {
217      const error = bodySourceError(action, `${where} (${action.type})`)
218      if (error) errors.push(error)
219      break
220    }
221    case 'send': {
222      const error = bodySourceError(action, `${where} (send)`)
223      if (error) errors.push(error)
224      need(action.to === 'client' || action.to === 'server', 'to must be client or server')
225      break
226    }
227    case 'close':
228      if (action.code !== undefined) need(isNumber(action.code, 1000, 4999) && Number.isInteger(action.code), 'code must be 1000 to 4999')
229      if (action.reason !== undefined) need(isString(action.reason) && action.reason.length <= 123, 'reason must be text of at most 123 characters')
230      break
231    case 'mergeJson':
232      need(isObject(action.json) || Array.isArray(action.json), 'json must be an object or an array')
233      break
234    case 'respond': {
235      need(isNumber(action.status, 100, 599) && Number.isInteger(action.status), 'status must be 100 to 599')
236      if (action.headers !== undefined) need(isObject(action.headers) && Object.values(action.headers).every(isString), 'headers must map names to strings')
237      const given = ['text', 'json', 'file'].filter(key => action[key] !== undefined)
238      need(given.length <= 1, 'give at most one of text, json, file')
239      break
240    }
241    case 'setStatus':
242      need(isNumber(action.status, 100, 599) && Number.isInteger(action.status), 'status must be 100 to 599')
243      break
244    case 'fail':
245      need(['reset', 'close', 'timeout'].includes(action.kind), 'kind must be reset, close or timeout')
246      break
247    case 'script':
248      need(isString(action.code) && action.code.trim() !== '', 'code must be a non-empty string')
249      break
250    case 'breakpoint':
251      if (action.timeoutMs !== undefined) need(isNumber(action.timeoutMs, 100, 3_600_000), 'timeoutMs must be 100 to 3600000')
252      break
253  }
254  return errors
255}
256
257function matchErrors(match, where) {
258  if (match === undefined) return []
259  if (!isObject(match)) return [`${where} must be an object`]
260  const errors = []
261  for (const key of Object.keys(match)) {
262    if (!MATCH_KEYS.includes(key)) errors.push(`${where}: unknown key ${JSON.stringify(key)} (known: ${MATCH_KEYS.join(', ')})`)
263  }
264  for (const key of ['url', 'host', 'path', 'contentType']) {
265    if (match[key] !== undefined) {
266      const error = patternError(match[key], `${where}.${key}`)
267      if (error) errors.push(error)
268    }
269  }
270  if (match.methods !== undefined && !(Array.isArray(match.methods) && match.methods.length > 0 && match.methods.every(isString))) {
271    errors.push(`${where}.methods must be a non-empty list of strings`)
272  }
273  for (const key of ['headers', 'query']) {
274    if (match[key] === undefined) continue
275    if (!isObject(match[key])) errors.push(`${where}.${key} must map names to patterns`)
276    else {
277      for (const [name, pattern] of Object.entries(match[key])) {
278        const error = patternError(pattern, `${where}.${key}.${name}`)
279        if (error) errors.push(error)
280      }
281    }
282  }
283  if (match.bodyContains !== undefined && (!isString(match.bodyContains) || match.bodyContains === '')) {
284    errors.push(`${where}.bodyContains must be a non-empty string`)
285  }
286  if (match.status !== undefined && !statusMatcher(match.status)) {
287    errors.push(`${where}.status must look like 404, 4xx, >=400 or 500-599`)
288  }
289  return errors
290}
291
292/** What is wrong with one rule, as sentences; empty when nothing is. */
293export function ruleErrors(rule, where = 'rule') {
294  if (!isObject(rule)) return [`${where} must be an object`]
295  const label = isString(rule.id) ? `rule ${rule.id}` : where
296  const errors = []
297  if (!isString(rule.id) || !ID.test(rule.id)) errors.push(`${label}: id must be 1-64 of letters, digits, ., _ and - (got ${JSON.stringify(rule.id)})`)
298  for (const key of ['name', 'description']) {
299    if (rule[key] !== undefined && !isString(rule[key])) errors.push(`${label}: ${key} must be a string`)
300  }
301  for (const key of ['enabled', 'stop']) {
302    if (rule[key] !== undefined && typeof rule[key] !== 'boolean') errors.push(`${label}: ${key} must be true or false`)
303  }
304  errors.push(...matchErrors(rule.match, `${label}: match`))
305  for (const phase of ['request', 'response', 'messages']) {
306    if (rule[phase] === undefined) continue
307    if (!Array.isArray(rule[phase])) {
308      errors.push(`${label}: ${phase} must be a list of ${phase === 'messages' ? 'steps' : 'actions'}`)
309      continue
310    }
311    rule[phase].forEach((action, i) => errors.push(...actionErrors(action, phase, `${label}: ${phase}[${i}]`)))
312  }
313  if (!(rule.request?.length || rule.response?.length || rule.messages?.length)) errors.push(`${label}: give at least one action in request, response or messages`)
314  const responseOnly = rule.match && (rule.match.status !== undefined || rule.match.contentType !== undefined)
315  if (responseOnly && rule.request?.length) errors.push(`${label}: match.status and match.contentType are known only after the response, so such a rule takes response actions only`)
316  return errors
317}
318
319/**
320 * Reads a rules file's text. A rule with errors is kept, with its errors,
321 * and never applied; `errors` holds the file's own problems.
322 *
323 * @returns `{ rules: [{ rule, errors }], errors }`
324 */
325export function parseRules(text) {
326  if (text === null || text === undefined || String(text).trim() === '') return { rules: [], errors: [] }
327  let data
328  try {
329    data = JSON.parse(text)
330  } catch (error) {
331    return { rules: [], errors: [`not valid JSON: ${error.message}`] }
332  }
333  if (!isObject(data) || !Array.isArray(data.rules)) return { rules: [], errors: ['the file must be {"rules": [...]}'] }
334  const seen = new Set()
335  const rules = data.rules.map((rule, index) => {
336    const errors = ruleErrors(rule, `rules[${index}]`)
337    if (isObject(rule) && isString(rule.id)) {
338      if (seen.has(rule.id)) errors.push(`rule ${rule.id}: another rule has this id`)
339      seen.add(rule.id)
340    }
341    return { rule, errors }
342  })
343  return { rules, errors: [] }
344}
345
346export function isEnabled(rule) {
347  return rule.enabled !== false
348}
349
350// --- matching -----------------------------------------------------------------
351//
352// A request is `{ method, url, host, path, headers, body }`: `path` with its
353// query, `headers` as [name, value] pairs, `body` the text when it was read.
354// A response is `{ status, contentType }`.
355
356function headerValue(headers, name) {
357  const lower = name.toLowerCase()
358  return headers.find(([key]) => key.toLowerCase() === lower)?.[1]
359}
360
361/** Whether the request-side conditions hold (status and contentType wait for the response). */
362export function matchesRequest(rule, request) {
363  const match = rule.match ?? {}
364  if (match.methods && !match.methods.some(method => method.toUpperCase() === request.method.toUpperCase())) return false
365  if (match.url && !matchesPattern(match.url, request.url)) return false
366  if (match.host && !matchesHost(match.host, request.host)) return false
367  if (match.path && !matchesPattern(match.path, request.path.split('?')[0])) return false
368  if (match.headers) {
369    for (const [name, pattern] of Object.entries(match.headers)) {
370      const value = headerValue(request.headers, name)
371      if (value === undefined || !matchesPattern(pattern, value)) return false
372    }
373  }
374  if (match.query) {
375    const params = new URLSearchParams(request.path.split('?').slice(1).join('?'))
376    for (const [name, pattern] of Object.entries(match.query)) {
377      const values = params.getAll(name)
378      if (values.length === 0 || !values.some(value => matchesPattern(pattern, value))) return false
379    }
380  }
381  if (match.bodyContains !== undefined && !(request.body ?? '').includes(match.bodyContains)) return false
382  return true
383}
384
385export function matchesResponse(rule, response) {
386  const match = rule.match ?? {}
387  if (match.status !== undefined && !statusMatcher(match.status)(response.status)) return false
388  if (match.contentType !== undefined) {
389    const type = response.contentType ?? ''
390    const pattern = match.contentType
391    const holds = isRegexPattern(pattern) || pattern.includes('*') ? matchesPattern(pattern, type) : type.toLowerCase().includes(pattern.toLowerCase())
392    if (!holds) return false
393  }
394  return true
395}
396
397/** The request's body must be read before the rule can match or act. */
398export function needsRequestBody(rule) {
399  return rule.match?.bodyContains !== undefined || (rule.request ?? []).some(action => BODY_ACTIONS.has(action.type))
400}
401
402/** The response's body must be held back so the rule can change it. */
403export function needsResponseBody(rule) {
404  return (rule.response ?? []).some(action => BODY_ACTIONS.has(action.type))
405}
406
407export function scriptsOf(rule) {
408  return [...(rule.request ?? []), ...(rule.response ?? []), ...(rule.messages ?? [])].filter(action => action?.type === 'script').map(action => action.code)
409}
410
411/** Whether a rule acts on plain HTTP exchanges (and not only on WebSocket messages). */
412export function actsOnHttp(rule) {
413  return (rule.request?.length ?? 0) > 0 || (rule.response?.length ?? 0) > 0
414}
415
416/** Whether a message step takes this message: `{ direction: 'out'|'in', text }`, `text` null when binary. */
417export function stepTakes(step, message) {
418  if ((step.on ?? 'message') !== 'message') return false
419  const direction = step.direction ?? 'both'
420  if (direction !== 'both' && direction !== message.direction) return false
421  if (step.when === undefined) return true
422  if (message.text === null) return false
423  return isRegexPattern(step.when) ? new RegExp(step.when.slice(3)).test(message.text) : message.text.includes(step.when)
424}
425
426// --- text -------------------------------------------------------------------
427
428function short(value, length = 40) {
429  const text = typeof value === 'string' ? value : JSON.stringify(value)
430  return text.length > length ? `${text.slice(0, length - 1)}…` : text
431}
432
433function duration(ms) {
434  return ms >= 1000 && ms % 100 === 0 ? `${ms / 1000} s` : `${ms} ms`
435}
436
437function bytes(n) {
438  return n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB/s` : n >= 1024 ? `${Math.round(n / 1024)} KB/s` : `${n} B/s`
439}
440
441function bodySource(action) {
442  if (action.file !== undefined) return `file ${action.file}`
443  if (action.json !== undefined) return `JSON ${short(action.json)}`
444  if (action.text !== undefined) return `"${short(action.text)}"`
445  return 'an empty body'
446}
447
448export function describeAction(action) {
449  switch (action?.type) {
450    case 'delay':
451      return action.msMax !== undefined && action.msMax > action.ms ? `wait ${duration(action.ms)} to ${duration(action.msMax)}` : `wait ${duration(action.ms)}`
452    case 'throttle':
453      return `throttle to ${bytes(action.bytesPerSecond)}`
454    case 'setHeader':
455      return `set header ${action.name}: ${short(action.value, 30)}`
456    case 'removeHeader':
457      return `remove header ${action.name}`
458    case 'setQuery':
459      return `set query ${action.name}=${short(action.value, 30)}`
460    case 'removeQuery':
461      return `remove query ${action.name}`
462    case 'mapRemote': {
463      const parts = [action.scheme && `${action.scheme}://`, action.host, action.port && `:${action.port}`, action.path].filter(Boolean)
464      return `send to ${parts.join('')}`
465    }
466    case 'replaceUrl':
467      return `rewrite URL ${action.pattern} → ${short(action.with, 30)}`
468    case 'setBody':
469      return `set body to ${bodySource(action)}`
470    case 'replaceBody':
471      return `replace ${action.pattern} with "${short(action.with, 30)}" in the body`
472    case 'mergeJson':
473      return `merge JSON ${short(action.json)}`
474    case 'respond':
475      return `answer ${action.status} with ${bodySource(action)} without asking the server`
476    case 'setStatus':
477      return `set status ${action.status}`
478    case 'fail':
479      return action.kind === 'timeout' ? 'never answer (time out)' : action.kind === 'reset' ? 'reset the connection' : 'close the connection'
480    case 'script':
481      return `run a script (${action.code.split('\n').length} lines)`
482    case 'breakpoint':
483      return `pause for you or Claude to look and change (let go after ${duration(action.timeoutMs ?? 300_000)})`
484    case 'replaceMessage':
485      return `replace ${action.pattern} with "${short(action.with, 30)}"`
486    case 'setMessage':
487      return `set it to ${bodySource(action)}`
488    case 'drop':
489      return 'drop it'
490    case 'reply':
491      return `reply ${bodySource(action)} instead of passing it on`
492    case 'send':
493      return `send ${bodySource(action)} to the ${action.to}`
494    case 'close':
495      return `close the WebSocket (${action.code ?? 1000}${action.reason ? ` "${short(action.reason, 30)}"` : ''})`
496    default:
497      return `unknown action ${JSON.stringify(action?.type)}`
498  }
499}
500
501export function describeMatch(match = {}) {
502  const parts = []
503  if (match.methods) parts.push(match.methods.map(method => method.toUpperCase()).join(', '))
504  if (match.url) parts.push(`URL ${match.url}`)
505  if (match.host) parts.push(`host ${match.host}`)
506  if (match.path) parts.push(`path ${match.path}`)
507  for (const [name, pattern] of Object.entries(match.query ?? {})) parts.push(`query ${name}=${pattern}`)
508  for (const [name, pattern] of Object.entries(match.headers ?? {})) parts.push(`header ${name}: ${pattern}`)
509  if (match.bodyContains !== undefined) parts.push(`body has "${short(match.bodyContains, 30)}"`)
510  if (match.status !== undefined) parts.push(`status ${match.status}`)
511  if (match.contentType !== undefined) parts.push(`type ${match.contentType}`)
512  return parts.length ? parts.join(' · ') : 'every request'
513}
514
515/** A message step in words: which messages, then what it does to them. */
516export function describeStep(step) {
517  if (step.on === 'open') return `on open: ${describeAction(step)}`
518  const direction = step.direction === 'out' ? 'client → server' : step.direction === 'in' ? 'server → client' : 'each message'
519  const when = step.when === undefined ? '' : isRegexPattern(step.when) ? ` matching ${step.when}` : ` holding "${short(step.when, 30)}"`
520  return `${direction}${when}: ${describeAction(step)}`
521}
522
523/** One line a person reads: what the rule catches and what it does there. */
524export function describeRule(rule) {
525  const phases = []
526  if (rule.request?.length) phases.push(`before sending: ${rule.request.map(describeAction).join(', ')}`)
527  if (rule.response?.length) phases.push(`on the response: ${rule.response.map(describeAction).join(', ')}`)
528  if (rule.messages?.length) phases.push(`WebSocket messages: ${rule.messages.map(describeStep).join('; ')}`)
529  return `${describeMatch(rule.match)} → ${phases.join('; ')}${rule.stop ? '; then stop' : ''}`
530}
531
shared/systemproxy.mjs 77 lines
1// The macOS system proxy: what networksetup says, the commands that point a
2// network service at the proxy, and the ones that put it back exactly as it
3// was. Plain JavaScript: the mod runs the commands through $.process.run, the
4// sidecar through child_process when Claude Code is gone without a word.
5
6/** Hosts the system proxy never sends to the proxy: Claude's own, and the usual local ones. */
7export const BYPASS = ['*.local', '169.254/16', 'localhost', '127.0.0.1', '*.anthropic.com', 'anthropic.com', '*.claude.ai', 'claude.ai', '*.claude.com', 'claude.com']
8
9/** `networksetup -listnetworkserviceorder` as [{ name, device, isDisabled }]. */
10export function parseServiceOrder(text) {
11  const services = []
12  const lines = String(text).split('\n')
13  for (let i = 0; i < lines.length; i++) {
14    const head = /^\((\d+|\*)\)\s+(.+)$/.exec(lines[i].trim())
15    if (!head) continue
16    const port = /Device:\s*([^)]*)\)/.exec(lines[i + 1] ?? '')
17    services.push({ name: head[2].replace(/^\*\s*/, ''), device: port?.[1]?.trim() ?? '', isDisabled: head[1] === '*' || head[2].startsWith('*') })
18  }
19  return services
20}
21
22/** `networksetup -getwebproxy <service>` (or -getsecurewebproxy) as { enabled, server, port }. */
23export function parseProxyState(text) {
24  const value = key => new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(String(text))?.[1]?.trim() ?? ''
25  return { enabled: value('Enabled') === 'Yes', server: value('Server'), port: Number(value('Port')) || 0 }
26}
27
28/** `networksetup -getproxybypassdomains <service>` as a list; empty when none is set. */
29export function parseBypass(text) {
30  const lines = String(text).split('\n').map(line => line.trim()).filter(Boolean)
31  return lines.length === 1 && /aren't any|There are no/i.test(lines[0]) ? [] : lines
32}
33
34/** Points `service` at the proxy for HTTP and HTTPS, keeping its bypass list and adding Claude's hosts. */
35export function enableCommands(service, port, previousBypass = []) {
36  const bypass = [...new Set([...previousBypass, ...BYPASS])]
37  return [
38    ['networksetup', '-setwebproxy', service, '127.0.0.1', String(port)],
39    ['networksetup', '-setsecurewebproxy', service, '127.0.0.1', String(port)],
40    ['networksetup', '-setproxybypassdomains', service, ...bypass],
41  ]
42}
43
44/**
45 * Puts the service back as the backup recorded it:
46 * `{ service, previous: { web, secure, bypass } }`.
47 */
48export function restoreCommands(backup) {
49  const { service, previous } = backup
50  const commands = []
51  for (const [kind, state] of [['web', previous.web], ['secure', previous.secure]]) {
52    const set = kind === 'web' ? '-setwebproxy' : '-setsecurewebproxy'
53    const toggle = kind === 'web' ? '-setwebproxystate' : '-setsecurewebproxystate'
54    if (state.enabled && state.server && state.port) commands.push(['networksetup', set, service, state.server, String(state.port)])
55    else {
56      if (state.server && state.port) commands.push(['networksetup', set, service, state.server, String(state.port)])
57      commands.push(['networksetup', toggle, service, 'off'])
58    }
59  }
60  commands.push(['networksetup', '-setproxybypassdomains', service, ...(previous.bypass.length ? previous.bypass : ['Empty'])])
61  return commands
62}
63
64function shellQuote(text) {
65  return `'${String(text).replace(/'/g, `'\\''`)}'`
66}
67
68/** The commands as one line for `do shell script ... with administrator privileges`. */
69export function asAdminScript(commands) {
70  const line = commands.map(argv => argv.map(shellQuote).join(' ')).join(' && ')
71  return `do shell script "${line.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" with administrator privileges`
72}
73
74export function needsAdmin(output) {
75  return /requires admin|administrator|not authorized|permission/i.test(String(output))
76}
77
hooks/flows.ts 613 lines
1// Pure logic shared by the pane and the tools: the sidecar's line protocol,
2// the flow list, the filter language and the formats.
3
4import type { ProxyAddress, ProxyCa, ProxyFlow, ProxyHeld, ProxySession } from '../types'
5
6export type SidecarEvent =
7  | {
8      t: 'ready'
9      host: string
10      port: number
11      addresses: string[]
12      lan?: ProxyAddress[]
13      pid: number
14      runDir: string
15      ca: ProxyCa
16      control?: { token: string }
17      isShared?: boolean
18      version?: string
19      sessions?: ProxySession[]
20    }
21  | { t: 'attached'; pid: number; proxyPid: number; isStarted: boolean; version?: string }
22  | { t: 'sessions'; sessions: ProxySession[] }
23  | { t: 'pinned'; client: string | null; host: string }
24  | { t: 'unpinned'; client: string | null; host: string }
25  | { t: 'cleared' }
26  | { t: 'tick' }
27  | ({ t: 'held' } & ProxyHeld)
28  | { t: 'released'; id: number }
29  | { t: 'stopping' }
30  | { t: 'flow'; flow: ProxyFlow }
31  | { t: 'network'; lan: ProxyAddress[] }
32  | { t: 'rules'; file: string; total: number; active: number; errors: string[]; untrusted: string[] }
33  | { t: 'tracking'; enabled: boolean; patterns: string[] }
34  | { t: 'skipped'; hosts: Record<string, number> }
35  | { t: 'system-proxy'; isOn: boolean }
36  | { t: 'fatal'; code: string; message: string }
37  | { t: 'log'; level: string; message: string; source?: string }
38
39export type FlowBody = {
40  file: string
41  size: number
42  stored: number
43  isTruncated: boolean
44  encoding: string | null
45  isDecoded: boolean
46  /** A readable rendering the sidecar wrote beside a body it can decode. */
47  view?: string
48  /** What `view` decodes: gRPC messages or a bare protobuf message, both without a schema. */
49  viewKind?: 'grpc' | 'protobuf'
50}
51
52export type FlowDetail = ProxyFlow & {
53  url: string
54  httpVersion?: string
55  /** The protocol the server was spoken to in; HTTP/2 when the client and the server both speak it. */
56  upstreamHttpVersion?: string | null
57  statusMessage?: string | null
58  reqHeaders: [string, string][]
59  resHeaders: [string, string][]
60  /** Trailing headers after the response body (gRPC's status travels there). */
61  resTrailers?: [string, string][]
62  /** A WebSocket's message log (JSON lines), how many it holds, and how it closed. */
63  ws?: { file: string; count: number; close: { code: number | null; reason: string; by: string } | null; isMock?: boolean }
64  /** A text/event-stream response's events (JSON lines) and their count. */
65  sse?: { file: string; count: number }
66  /** What the rules did, one line each, `id: what`. */
67  ruleLog?: string[]
68  req: FlowBody | null
69  res: FlowBody | null
70}
71
72/** One WebSocket message as the sidecar records it: ms since the upgrade, out = client to server. */
73export type WsRecord = {
74  t: number
75  dir: 'out' | 'in'
76  op: string
77  size: number
78  text?: string
79  b64?: string
80  /** A binary message read without its schema (protobuf as protoc --decode_raw shows it, or text), and what it was read as. */
81  view?: string
82  viewKind?: string
83  code?: number | null
84  reason?: string
85  isCut?: boolean
86  note?: string
87  was?: string
88}
89
90/** One server-sent event as recorded: ms since the response began. */
91export type SseRecord = { t: number; event?: string; id?: string; data: string; retry?: number; isCut?: boolean }
92
93/** The records of a JSON-lines log, the broken lines left out. */
94export function parseRecords<T>(text: string): T[] {
95  const out: T[] = []
96  for (const line of text.split('\n')) {
97    if (!line.trim()) continue
98    try {
99      out.push(JSON.parse(line) as T)
100    } catch {}
101  }
102  return out
103}
104
105function seconds(ms: number): string {
106  return `+${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`
107}
108
109/** A message as one line: number, time, direction, kind, size, the text cut at `max`, and what a rule did. */
110export function wsLine(record: WsRecord, n: number, max = 300): string {
111  const arrow = record.dir === 'out' ? '→ server' : '← client'
112  const body =
113    record.op === 'close'
114      ? `close ${record.code ?? ''}${record.reason ? ` "${record.reason}"` : ''}`
115      : record.text !== undefined
116        ? truncate(record.text.replace(/\s*\n\s*/g, ' '), max)
117        : record.view !== undefined
118          ? `${record.size}B ${record.viewKind ?? 'decoded'}: ${truncate(record.view.replace(/\s*\n\s*/g, ' '), max)}`
119          : record.b64 !== undefined
120            ? `${record.op} ${record.size}B base64 ${truncate(record.b64, Math.min(max, 120))}`
121            : record.op
122  const note = record.note ? `  [${record.note}${record.was !== undefined ? `; was: ${truncate(record.was, 80)}` : ''}]` : ''
123  return `${n}. ${seconds(record.t)} ${arrow} ${record.op === 'text' ? '' : `(${record.op}) `}${body}${note}`
124}
125
126export function sseLine(record: SseRecord, n: number, max = 300): string {
127  const name = record.event ? `${record.event} ` : ''
128  const id = record.id !== undefined ? ` id=${record.id}` : ''
129  return `${n}. ${seconds(record.t)} ${name}${truncate(record.data.replace(/\s*\n\s*/g, ' '), max)}${id}`
130}
131
132/** What a streaming exchange carried so far: `↑2 ↓5` messages, or `12 events`; empty for others. */
133export function streamNote(flow: Pick<ProxyFlow, 'kind' | 'wsOut' | 'wsIn' | 'sseEvents'>): string {
134  if (flow.kind === 'ws' && (flow.wsOut !== undefined || flow.wsIn !== undefined)) return `↑${flow.wsOut ?? 0} ↓${flow.wsIn ?? 0}`
135  if (flow.sseEvents !== undefined) return `${flow.sseEvents} ev`
136  return ''
137}
138
139// --- the line protocol ------------------------------------------------------
140
141/** Splits what arrived after `rest` into whole lines and what is left over. */
142export function splitLines(rest: string, text: string): { lines: string[]; rest: string } {
143  const joined = rest + text
144  const parts = joined.split('\n')
145  const tail = parts.pop() ?? ''
146  return { lines: parts.filter(line => line.trim() !== ''), rest: tail }
147}
148
149export function parseEvent(line: string): SidecarEvent | null {
150  try {
151    const value = JSON.parse(line) as { t?: unknown }
152    return typeof value?.t === 'string' ? (value as SidecarEvent) : null
153  } catch {
154    return null
155  }
156}
157
158/** The list with `updates` laid over it by id, oldest first, the newest `max` kept. */
159export function mergeFlows(list: readonly ProxyFlow[], updates: readonly ProxyFlow[], max: number): ProxyFlow[] {
160  if (updates.length === 0) return [...list]
161  const byId = new Map<number, ProxyFlow>()
162  for (const flow of list) byId.set(flow.id, flow)
163  for (const flow of updates) byId.set(flow.id, flow)
164  const merged = [...byId.values()].sort((a, b) => a.id - b.id)
165  return merged.length > max ? merged.slice(merged.length - max) : merged
166}
167
168// --- what a flow is ------------------------------------------------------------
169
170export function flowUrl(flow: Pick<ProxyFlow, 'scheme' | 'host' | 'port' | 'path' | 'kind'>): string {
171  if (flow.kind === 'tunnel') return `${flow.host}:${flow.port}`
172  const isDefaultPort = (flow.scheme === 'https' && flow.port === 443) || (flow.scheme === 'http' && flow.port === 80)
173  const scheme = flow.kind === 'ws' ? (flow.scheme === 'https' ? 'wss' : 'ws') : flow.scheme
174  return `${scheme}://${flow.host}${isDefaultPort ? '' : `:${flow.port}`}${flow.path}`
175}
176
177export type FlowType = 'json' | 'html' | 'xml' | 'js' | 'css' | 'img' | 'font' | 'media' | 'text' | 'form' | 'grpc' | 'ws' | 'tunnel' | 'other'
178
179export function typeOf(flow: Pick<ProxyFlow, 'kind' | 'contentType'>): FlowType {
180  if (flow.kind === 'ws') return 'ws'
181  if (flow.kind === 'tunnel') return 'tunnel'
182  const type = flow.contentType ?? ''
183  if (type.startsWith('application/grpc')) return 'grpc'
184  if (type.includes('json')) return 'json'
185  if (type.includes('html')) return 'html'
186  if (type.includes('xml')) return 'xml'
187  if (type.includes('javascript') || type.includes('ecmascript')) return 'js'
188  if (type.includes('css')) return 'css'
189  if (type.startsWith('image/')) return 'img'
190  if (type.startsWith('font/') || type.includes('woff')) return 'font'
191  if (type.startsWith('video/') || type.startsWith('audio/')) return 'media'
192  if (type.includes('form')) return 'form'
193  if (type.startsWith('text/')) return 'text'
194  return 'other'
195}
196
197export function isFailure(flow: ProxyFlow): boolean {
198  return flow.state === 'error' || (flow.status !== null && flow.status >= 400) || (flow.grpcStatus !== undefined && flow.grpcStatus !== 0)
199}
200
201const GRPC_CODES = [
202  'OK', 'CANCELLED', 'UNKNOWN', 'INVALID_ARGUMENT', 'DEADLINE_EXCEEDED', 'NOT_FOUND', 'ALREADY_EXISTS', 'PERMISSION_DENIED',
203  'RESOURCE_EXHAUSTED', 'FAILED_PRECONDITION', 'ABORTED', 'OUT_OF_RANGE', 'UNIMPLEMENTED', 'INTERNAL', 'UNAVAILABLE', 'DATA_LOSS',
204  'UNAUTHENTICATED',
205]
206
207/** `gRPC NOT_FOUND: no such greeter` for a call that ended with a status, null otherwise. */
208export function grpcLabel(flow: Pick<ProxyFlow, 'grpcStatus' | 'grpcMessage'>): string | null {
209  if (flow.grpcStatus === undefined) return null
210  const name = GRPC_CODES[flow.grpcStatus] ?? `status ${flow.grpcStatus}`
211  return `gRPC ${name}${flow.grpcMessage ? `: ${flow.grpcMessage}` : ''}`
212}
213
214export function isTextual(contentType: string | null): boolean {
215  const type = contentType ?? ''
216  return (
217    type === '' ||
218    type.startsWith('text/') ||
219    /json|xml|javascript|ecmascript|x-www-form-urlencoded|graphql|yaml|csv/.test(type)
220  )
221}
222
223// --- the filter language --------------------------------------------------------
224//
225// Terms separated by spaces all hold (AND); a leading `-` negates a term.
226// Free text: a substring of the URL. Keys: method:, status:, host:, path:,
227// type:, is:, client:. "Quoted text" keeps its spaces.
228
229type Term = { isNegated: boolean; test: (flow: ProxyFlow) => boolean }
230
231export type ParsedFilter = { terms: Term[]; errors: string[] }
232
233function tokenize(query: string): string[] {
234  const tokens: string[] = []
235  const pattern = /(-?)(?:(\w+):)?"([^"]*)"|(\S+)/g
236  for (const match of query.matchAll(pattern)) {
237    if (match[4] !== undefined) tokens.push(match[4])
238    else tokens.push(`${match[1]}${match[2] ? `${match[2]}:` : ''}${match[3]}`)
239  }
240  return tokens
241}
242
243function globToRegExp(glob: string): RegExp {
244  const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
245  return new RegExp(`^${escaped}$`, 'i')
246}
247
248function statusTest(value: string): ((flow: ProxyFlow) => boolean) | null {
249  const v = value.toLowerCase()
250  const has = (flow: ProxyFlow) => flow.status !== null
251  if (/^[1-5]xx$/.test(v)) {
252    const base = Number(v[0]) * 100
253    return flow => has(flow) && flow.status! >= base && flow.status! < base + 100
254  }
255  if (/^\d{3}$/.test(v)) return flow => flow.status === Number(v)
256  const range = /^(\d{3})-(\d{3})$/.exec(v)
257  if (range) return flow => has(flow) && flow.status! >= Number(range[1]) && flow.status! <= Number(range[2])
258  const compare = /^(>=|<=|>|<)(\d{3})$/.exec(v)
259  if (compare) {
260    const n = Number(compare[2])
261    const op = compare[1]
262    return flow =>
263      has(flow) &&
264      (op === '>=' ? flow.status! >= n : op === '<=' ? flow.status! <= n : op === '>' ? flow.status! > n : flow.status! < n)
265  }
266  if (v === 'none' || v === 'pending') return flow => flow.status === null
267  return null
268}
269
270const IS_TESTS: Record<string, (flow: ProxyFlow) => boolean> = {
271  error: isFailure,
272  err: isFailure,
273  ok: flow => !isFailure(flow) && flow.state === 'done',
274  pending: flow => flow.state === 'pending' || flow.state === 'receiving',
275  tunnel: flow => flow.kind === 'tunnel',
276  ws: flow => flow.kind === 'ws',
277  https: flow => flow.scheme === 'https',
278  http: flow => flow.scheme === 'http',
279  rejected: flow => flow.errorCode === 'client-rejected-cert',
280  modified: flow => (flow.rules?.length ?? 0) > 0,
281  h2: flow => flow.httpVersion === '2',
282  held: flow => flow.held !== undefined,
283  grpc: flow => typeOf(flow) === 'grpc',
284}
285
286export function parseFilter(query: string): ParsedFilter {
287  const terms: Term[] = []
288  const errors: string[] = []
289  for (const raw of tokenize(query.trim())) {
290    const isNegated = raw.startsWith('-') && raw.length > 1
291    const token = isNegated ? raw.slice(1) : raw
292    const keyed = /^(\w+):(.*)$/.exec(token)
293    const key = keyed?.[1]?.toLowerCase()
294    const value = keyed?.[2] ?? ''
295    let test: ((flow: ProxyFlow) => boolean) | null = null
296    switch (key) {
297      case 'method': {
298        const methods = value.toUpperCase().split(',').filter(Boolean)
299        test = flow => methods.includes(flow.method.toUpperCase())
300        break
301      }
302      case 'status':
303        test = statusTest(value)
304        if (!test) errors.push(`status:${value}`)
305        break
306      case 'host': {
307        const v = value.toLowerCase()
308        if (v.includes('*')) {
309          const re = globToRegExp(v)
310          // *.example.com also covers example.com itself
311          const apex = v.startsWith('*.') ? v.slice(2) : null
312          test = flow => re.test(flow.host) || flow.host === apex
313        } else {
314          test = flow => flow.host.includes(v)
315        }
316        break
317      }
318      case 'path': {
319        const v = value.toLowerCase()
320        test = flow => flow.path.toLowerCase().includes(v)
321        break
322      }
323      case 'type': {
324        const types = value.toLowerCase().split(',').map(t => (t === 'image' ? 'img' : t))
325        test = flow => types.includes(typeOf(flow))
326        break
327      }
328      case 'is':
329        test = IS_TESTS[value.toLowerCase()] ?? null
330        if (!test) errors.push(`is:${value}`)
331        break
332      case 'client':
333        test = flow => (flow.client ?? '').includes(value)
334        break
335      case 'rule':
336        test = flow => (flow.rules ?? []).includes(value)
337        break
338      default: {
339        const v = token.toLowerCase()
340        test = flow => flowUrl(flow).toLowerCase().includes(v)
341      }
342    }
343    if (test) terms.push({ isNegated, test })
344  }
345  return { terms, errors }
346}
347
348export function matchesFilter(flow: ProxyFlow, filter: ParsedFilter): boolean {
349  return filter.terms.every(term => term.test(flow) !== term.isNegated)
350}
351
352export function filterFlows(flows: readonly ProxyFlow[], query: string): ProxyFlow[] {
353  const filter = parseFilter(query)
354  return filter.terms.length === 0 ? [...flows] : flows.filter(flow => matchesFilter(flow, filter))
355}
356
357// --- formats -----------------------------------------------------------------
358
359export function formatSize(bytes: number): string {
360  if (bytes < 1024) return `${bytes}B`
361  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)}KB`
362  return `${(bytes / 1024 / 1024).toFixed(1)}MB`
363}
364
365export function formatDuration(ms: number | null): string {
366  if (ms === null) return '…'
367  if (ms < 1000) return `${ms}ms`
368  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
369  return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
370}
371
372export function statusLabel(flow: ProxyFlow): string {
373  if (flow.held) return 'HELD'
374  if (flow.errorCode === 'client-rejected-cert') return 'CERT'
375  if (flow.state === 'error' && flow.status === null) return 'ERR'
376  if (flow.status === null) return '…'
377  return String(flow.status)
378}
379
380export function truncate(text: string, width: number): string {
381  if (width <= 0) return ''
382  return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
383}
384
385/** The flow's URL in at most `max` characters: the end (the query first) cut, and how much was. */
386export function modelUrl(flow: Pick<ProxyFlow, 'scheme' | 'host' | 'port' | 'path' | 'kind'>, max = 160): string {
387  const url = flowUrl(flow)
388  if (url.length <= max) return url
389  let keep = max - 4
390  let suffix = ''
391  for (;;) {
392    suffix = `…(+${url.length - keep})`
393    if (keep + suffix.length <= max || keep <= 1) break
394    keep -= 1
395  }
396  return `${url.slice(0, keep)}${suffix}`
397}
398
399/** One line per flow for the model: aligned, newest last. */
400export function flowTable(flows: readonly ProxyFlow[]): string {
401  return flows
402    .map(flow => {
403      const stream = flow.kind === 'ws' && streamNote(flow) ? ` messages ${streamNote(flow)}` : flow.sseEvents !== undefined ? ` ${flow.sseEvents} events` : ''
404      const grpc = flow.grpcStatus ? grpcLabel(flow) : null
405      const error = flow.error ? `  ! ${truncate(flow.error, 160)}` : grpc ? `  ! ${truncate(grpc, 160)}` : ''
406      const rules = flow.rules?.length ? `  rules: ${flow.rules.join(', ')}` : ''
407      const replay = flow.replayOf ? `  replay of #${flow.replayOf}` : ''
408      return `#${flow.id}  ${flow.method.padEnd(7)} ${statusLabel(flow).padEnd(4)} ${modelUrl(flow)}  ${formatSize(flow.resSize)}  ${formatDuration(flow.durationMs)}  ${typeOf(flow)}${stream}${replay}${rules}${error}`
409    })
410    .join('\n')
411}
412
413const CURL_SKIPPED = new Set(['host', 'content-length', 'connection', 'proxy-connection', 'keep-alive', 'transfer-encoding'])
414
415function shellQuote(text: string): string {
416  return `'${text.replace(/'/g, `'\\''`)}'`
417}
418
419/** The request as a curl command; a body too long or not text is referenced by its file. */
420export function toCurl(detail: FlowDetail, body: string | null): string {
421  // One line per flag and its value, continued with backslashes.
422  const lines = [`curl${detail.method !== 'GET' ? ` -X ${detail.method}` : ''} ${shellQuote(detail.url)}`]
423  let isCompressed = false
424  for (const [name, value] of detail.reqHeaders) {
425    const lower = name.toLowerCase()
426    if (CURL_SKIPPED.has(lower)) continue
427    if (lower === 'accept-encoding') {
428      isCompressed = true
429      continue
430    }
431    lines.push(`-H ${shellQuote(`${name}: ${value}`)}`)
432  }
433  if (isCompressed) lines.push('--compressed')
434  if (detail.req) {
435    if (body !== null && body.length <= 8000 && !detail.req.isTruncated) lines.push(`--data-raw ${shellQuote(body)}`)
436    else lines.push(`--data-binary @${shellQuote(detail.req.file)}`)
437  }
438  return lines.join(' \\\n  ')
439}
440
441/** JSON pretty-printed when it parses; the text as it came otherwise. */
442export function prettyBody(text: string, contentType: string | null): string {
443  if ((contentType ?? '').includes('json') || /^\s*[[{]/.test(text)) {
444    try {
445      return JSON.stringify(JSON.parse(text), null, 2)
446    } catch {
447      return text
448    }
449  }
450  return text
451}
452
453export function languageOf(contentType: string | null): string | undefined {
454  const type = contentType ?? ''
455  if (type.includes('json')) return 'json'
456  if (type.includes('html')) return 'html'
457  if (type.includes('xml')) return 'xml'
458  if (type.includes('javascript')) return 'javascript'
459  if (type.includes('css')) return 'css'
460  if (type.includes('graphql')) return 'graphql'
461  if (type.includes('yaml')) return 'yaml'
462  return undefined
463}
464
465/** At most `maxLines` lines and `maxChars` characters, saying what was cut. */
466export function clip(text: string, maxLines: number, maxChars: number): { text: string; isClipped: boolean } {
467  let out = text.length > maxChars ? text.slice(0, maxChars) : text
468  const lines = out.split('\n')
469  if (lines.length > maxLines) out = lines.slice(0, maxLines).join('\n')
470  return { text: out, isClipped: out.length < text.length }
471}
472
473// --- find in a request ------------------------------------------------------------
474
475/** One occurrence of the text searched for: its line and where it starts and ends. */
476export type FindMatch = { line: number; start: number; end: number }
477
478/** Every occurrence of `query` in `lines`, case aside, in reading order; at most `limit`. */
479export function findMatches(lines: readonly string[], query: string, limit = 10_000): FindMatch[] {
480  const needle = query.toLowerCase()
481  const out: FindMatch[] = []
482  if (!needle) return out
483  for (let line = 0; line < lines.length && out.length < limit; line++) {
484    const text = lines[line]!
485    const hay = text.toLowerCase()
486    // a lower case that changes the length (a few scripts) would shift the marks: search as it is
487    const source = hay.length === text.length ? hay : text
488    const wanted = hay.length === text.length ? needle : query
489    let at = source.indexOf(wanted)
490    while (at >= 0 && out.length < limit) {
491      out.push({ line, start: at, end: at + wanted.length })
492      at = source.indexOf(wanted, at + wanted.length)
493    }
494  }
495  return out
496}
497
498/** The lines to show, `size` of `total`: from the top, or starting a few lines above the current match. */
499export function findWindow(total: number, matchLine: number | null, size: number): { from: number; to: number } {
500  if (matchLine === null || total <= size) return { from: 0, to: Math.min(total, size) }
501  const from = Math.max(0, Math.min(matchLine - 3, total - size))
502  return { from, to: from + size }
503}
504
505/** A line in pieces: plain text, and each match with its number among all of them. */
506export function splitByMatches(text: string, matches: readonly { match: FindMatch; n: number }[]): { text: string; n: number | null }[] {
507  const pieces: { text: string; n: number | null }[] = []
508  let at = 0
509  for (const { match, n } of [...matches].sort((a, b) => a.match.start - b.match.start)) {
510    if (match.start < at) continue
511    if (match.start > at) pieces.push({ text: text.slice(at, match.start), n: null })
512    pieces.push({ text: text.slice(match.start, match.end), n })
513    at = match.end
514  }
515  if (at < text.length || pieces.length === 0) pieces.push({ text: text.slice(at), n: null })
516  return pieces
517}
518
519// --- the tree view ---------------------------------------------------------------
520//
521// Requests grouped by origin, then by path segment, the way a file tree groups
522// files: `https://api.example.com` → `/v1` → `/items`. A node with no requests
523// of its own and one child is folded into it (`/v1/items`). Origins and
524// segments sort by name so the tree holds still while traffic flows; the
525// requests under a node are newest first.
526
527export type TreeNode = {
528  /** Stable across redraws: what the expanded set holds. */
529  id: string
530  label: string
531  /** Requests under this node, its descendants' included. */
532  count: number
533  /** Of those, the ones that failed (isFailure). */
534  errors: number
535  children: TreeNode[]
536  /** Requests to exactly this path, newest first. */
537  flows: ProxyFlow[]
538}
539
540export type TreeRow =
541  | { kind: 'node'; node: TreeNode; depth: number; isOpen: boolean }
542  | { kind: 'flow'; flow: ProxyFlow; depth: number }
543
544function originOf(flow: ProxyFlow): string {
545  if (flow.kind === 'tunnel') return `${flow.host}:${flow.port}`
546  return flowUrl({ ...flow, kind: 'http', path: '' })
547}
548
549type Building = { id: string; label: string; children: Map<string, Building>; flows: ProxyFlow[] }
550
551function finish(node: Building): TreeNode {
552  const children = [...node.children.values()].map(finish).sort((a, b) => a.label.localeCompare(b.label))
553  const flows = [...node.flows].sort((a, b) => b.id - a.id)
554  // a path segment with no requests of its own and one child folds into it
555  if (node.id.startsWith('p:') && flows.length === 0 && children.length === 1) {
556    const only = children[0]!
557    return { ...only, label: `${node.label}${only.label}` }
558  }
559  const count = flows.length + children.reduce((sum, child) => sum + child.count, 0)
560  const errors = flows.filter(isFailure).length + children.reduce((sum, child) => sum + child.errors, 0)
561  return { id: node.id, label: node.label, count, errors, children, flows }
562}
563
564export function buildTree(flows: readonly ProxyFlow[]): TreeNode[] {
565  const origins = new Map<string, Building>()
566  for (const flow of flows) {
567    const origin = originOf(flow)
568    let node = origins.get(origin)
569    if (!node) {
570      node = { id: `o:${origin}`, label: origin, children: new Map(), flows: [] }
571      origins.set(origin, node)
572    }
573    const segments = flow.kind === 'tunnel' ? [] : flow.path.split('?')[0]!.split('/').filter(Boolean)
574    let at = node
575    let prefix = origin
576    for (const segment of segments) {
577      prefix = `${prefix}/${segment}`
578      let child = at.children.get(segment)
579      if (!child) {
580        child = { id: `p:${prefix}`, label: `/${segment}`, children: new Map(), flows: [] }
581        at.children.set(segment, child)
582      }
583      at = child
584    }
585    at.flows.push(flow)
586  }
587  return [...origins.values()].map(finish).sort((a, b) => a.label.localeCompare(b.label))
588}
589
590/** The rows to draw: open nodes show their child nodes, then their requests. */
591export function flattenTree(nodes: readonly TreeNode[], expanded: ReadonlySet<string>, depth = 0): TreeRow[] {
592  const rows: TreeRow[] = []
593  for (const node of nodes) {
594    const isOpen = expanded.has(node.id)
595    rows.push({ kind: 'node', node, depth, isOpen })
596    if (!isOpen) continue
597    rows.push(...flattenTree(node.children, expanded, depth + 1))
598    for (const flow of node.flows) rows.push({ kind: 'flow', flow, depth: depth + 1 })
599  }
600  return rows
601}
602
603/** Every node id in the tree: what "expand all" opens. */
604export function treeIds(nodes: readonly TreeNode[]): string[] {
605  return nodes.flatMap(node => [node.id, ...treeIds(node.children)])
606}
607
608/** What a request row in the tree says after its status and method. */
609export function treeLeafLabel(flow: ProxyFlow): string {
610  const query = flow.path.includes('?') ? `?${flow.path.split('?').slice(1).join('?')}` : ''
611  return `#${flow.id}${query ? ` ${query}` : ''}`
612}
613
hooks/android.ts 42 lines
1// The Android emulator's system CA store, for an image that allows `adb root`
2// (Google APIs and AOSP images; not Google Play ones): the CA joins the system
3// certificates on a tmpfs laid over the store, and on Android 14 and later the
4// store in the Conscrypt APEX is replaced too, in zygote's namespace and in
5// every running app's. It lasts until the emulator reboots.
6
7/** Where adb push puts the CA on the device. */
8export const DEVICE_CA = '/data/local/tmp/wirepane-ca.pem'
9
10/** The shell script, run as root on the device, that puts the CA (named `<hash>.0`) among the system CAs. */
11export function systemCaScript(hash: string): string {
12  return [
13    'set -e',
14    'STAGE=/data/local/tmp/wirepane-cacerts',
15    'rm -rf "$STAGE" && mkdir -p -m 700 "$STAGE"',
16    // the certificates the system has now, from the APEX where there is one
17    'if [ -d /apex/com.android.conscrypt/cacerts ]; then cp /apex/com.android.conscrypt/cacerts/* "$STAGE"/; else cp /system/etc/security/cacerts/* "$STAGE"/; fi',
18    'mount -t tmpfs tmpfs /system/etc/security/cacerts',
19    'mv "$STAGE"/* /system/etc/security/cacerts/',
20    `cp ${DEVICE_CA} /system/etc/security/cacerts/${hash}.0`,
21    'chown root:root /system/etc/security/cacerts/*',
22    'chmod 644 /system/etc/security/cacerts/*',
23    'chcon u:object_r:system_file:s0 /system/etc/security/cacerts/*',
24    'if [ -d /apex/com.android.conscrypt/cacerts ]; then',
25    '  for Z in $(pidof zygote zygote64); do nsenter --mount=/proc/$Z/ns/mnt -- /bin/mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts; done',
26    '  for P in $(for Z in $(pidof zygote zygote64); do ps -o PID -P $Z | grep -v PID; done); do nsenter --mount=/proc/$P/ns/mnt -- /bin/mount --bind /system/etc/security/cacerts /apex/com.android.conscrypt/cacerts || true; done',
27    'fi',
28    'rm -rf "$STAGE"',
29    'echo "wirepane: system CA in place"',
30  ].join('\n')
31}
32
33/** Whether the device holds the CA among its system CAs now. */
34export function hasSystemCaCommand(hash: string): string {
35  return `[ -f /system/etc/security/cacerts/${hash}.0 ] && echo yes || echo no`
36}
37
38/** `adb root` answers this on an image that does not allow it. */
39export function isRootRefused(output: string): boolean {
40  return /cannot run as root|production builds|not allowed/i.test(output)
41}
42
hooks/diff.ts 104 lines
1// Two captured requests side by side: what differs, one line each, in the
2// order a person checks (method and URL, query, headers, status, bodies).
3
4import type { FlowDetail } from './flows'
5
6export type Exchange = { detail: FlowDetail; reqText: string | null; resText: string | null }
7
8// headers that differ between any two requests and say nothing
9const NOISE = new Set(['date', 'age', 'x-wirepane-replay'])
10const MAX_LINES = 60
11
12function show(value: unknown): string {
13  if (value === undefined) return '(absent)'
14  const text = JSON.stringify(value)
15  return text.length > 80 ? `${text.slice(0, 79)}…` : text
16}
17
18function headerMap(headers: readonly [string, string][]): Map<string, { name: string; value: string }> {
19  const out = new Map<string, { name: string; value: string }>()
20  for (const [name, value] of headers) {
21    const key = name.toLowerCase()
22    const had = out.get(key)
23    out.set(key, { name: had?.name ?? name, value: had ? `${had.value}, ${value}` : value })
24  }
25  return out
26}
27
28function diffHeaders(where: string, a: readonly [string, string][], b: readonly [string, string][], ids: [string, string], out: string[]) {
29  const left = headerMap(a)
30  const right = headerMap(b)
31  for (const key of new Set([...left.keys(), ...right.keys()])) {
32    if (NOISE.has(key)) continue
33    const x = left.get(key)
34    const y = right.get(key)
35    if (x && !y) out.push(`${where} ${x.name}: only in ${ids[0]} (${show(x.value)})`)
36    else if (!x && y) out.push(`${where} ${y.name}: only in ${ids[1]} (${show(y.value)})`)
37    else if (x && y && x.value !== y.value) out.push(`${where} ${x.name}: ${show(x.value)} → ${show(y.value)}`)
38  }
39}
40
41function diffJson(where: string, path: string, a: unknown, b: unknown, out: string[]) {
42  if (out.length > MAX_LINES) return
43  const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object'
44  if (isObject(a) && isObject(b) && Array.isArray(a) === Array.isArray(b)) {
45    for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
46      diffJson(where, Array.isArray(a) ? `${path}[${key}]` : `${path}.${key}`, a[key], b[key], out)
47    }
48    return
49  }
50  if (JSON.stringify(a) !== JSON.stringify(b)) out.push(`${where} ${path || '(the whole)'}: ${show(a)} → ${show(b)}`)
51}
52
53function parse(text: string | null): { value: unknown } | null {
54  if (text === null) return null
55  try {
56    return { value: JSON.parse(text) }
57  } catch {
58    return null
59  }
60}
61
62function diffBodies(where: string, a: string | null, b: string | null, ids: [string, string], out: string[]) {
63  if (a === b) return
64  if (a === null || a === '') return void out.push(`${where}: only in ${ids[1]} (${b?.length ?? 0} chars)`)
65  if (b === null || b === '') return void out.push(`${where}: only in ${ids[0]} (${a.length} chars)`)
66  const x = parse(a)
67  const y = parse(b)
68  if (x && y) return diffJson(where, '', x.value, y.value, out)
69  let at = 0
70  while (at < a.length && at < b.length && a[at] === b[at]) at += 1
71  const around = (text: string) => JSON.stringify(text.slice(Math.max(0, at - 20), at + 40))
72  out.push(`${where}: differs from character ${at + 1}: ${around(a)} vs ${around(b)}`)
73}
74
75/** What differs between two exchanges, a line each; one line saying so when nothing does. */
76export function diffRequests(a: Exchange, b: Exchange): string[] {
77  const ids: [string, string] = [`#${a.detail.id}`, `#${b.detail.id}`]
78  const out: string[] = []
79  if (a.detail.method !== b.detail.method) out.push(`method: ${a.detail.method} → ${b.detail.method}`)
80  const ua = new URL(a.detail.url)
81  const ub = new URL(b.detail.url)
82  if (ua.origin !== ub.origin) out.push(`origin: ${ua.origin} → ${ub.origin}`)
83  if (ua.pathname !== ub.pathname) out.push(`path: ${ua.pathname} → ${ub.pathname}`)
84  const keys = new Set<string>()
85  ua.searchParams.forEach((_, key) => keys.add(key))
86  ub.searchParams.forEach((_, key) => keys.add(key))
87  for (const key of keys) {
88    const x = ua.searchParams.getAll(key)
89    const y = ub.searchParams.getAll(key)
90    if (x.join('\u0000') === y.join('\u0000')) continue
91    if (y.length === 0) out.push(`query ${key}: only in ${ids[0]} (${show(x.join(','))})`)
92    else if (x.length === 0) out.push(`query ${key}: only in ${ids[1]} (${show(y.join(','))})`)
93    else out.push(`query ${key}: ${show(x.join(','))} → ${show(y.join(','))}`)
94  }
95  diffHeaders('request header', a.detail.reqHeaders, b.detail.reqHeaders, ids, out)
96  diffBodies('request body', a.reqText, b.reqText, ids, out)
97  if (a.detail.status !== b.detail.status) out.push(`status: ${a.detail.status ?? 'none'} → ${b.detail.status ?? 'none'}`)
98  if ((a.detail.grpcStatus ?? null) !== (b.detail.grpcStatus ?? null)) out.push(`gRPC status: ${a.detail.grpcStatus ?? 'none'} → ${b.detail.grpcStatus ?? 'none'}`)
99  diffHeaders('response header', a.detail.resHeaders, b.detail.resHeaders, ids, out)
100  diffBodies('response body', a.resText, b.resText, ids, out)
101  if (out.length === 0) return ['no difference in method, URL, headers, status or bodies']
102  return out.length > MAX_LINES ? [...out.slice(0, MAX_LINES), `…and ${out.length - MAX_LINES} more`] : out
103}
104
hooks/doctor.ts 378 lines
1// The doctor: what stands between the person and their traffic, read from
2// what the proxy saw and what this Mac says, each finding with its fix. Pure:
3// the hooks module gathers the facts (networksetup, route, adb, the flows).
4
5import type { ProxyFlow, ProxyStatus, ProxyTracking } from '../types'
6
7export type FindingLevel = 'ok' | 'info' | 'warn' | 'fail'
8
9/** What the pane can do about a finding in one press. */
10export type DoctorAction =
11  | { kind: 'start' }
12  | { kind: 'restore-system-proxy' }
13  | { kind: 'trust-mac' }
14  | { kind: 'track'; patterns: string[] }
15  | { kind: 'insecure-host'; host: string }
16  | { kind: 'no-decrypt'; host: string }
17  | { kind: 'android-system-ca'; serial: string }
18  | { kind: 'setup'; tab: 'ios' | 'android' | 'cli' }
19  | { kind: 'upstream'; proxy: string }
20  | { kind: 'stop-old'; pid: number }
21
22export type Finding = { level: FindingLevel; title: string; detail?: string; fix?: string; action?: DoctorAction; label?: string }
23
24export type ProxyState = { enabled: boolean; server: string; port: number }
25
26export type AndroidFacts = { serial: string; sdk: number | null; proxy: string | null; isRootable: boolean; hasSystemCa: boolean }
27
28export type DoctorFacts = {
29  status: ProxyStatus
30  flows: readonly ProxyFlow[]
31  tracking: ProxyTracking
32  skipped: Record<string, number>
33  macTrust: 'trusted' | 'untrusted' | 'unknown'
34  /** The default network service's web and secure proxies; null when it could not be read. */
35  systemProxy: { service: string; web: ProxyState; secure: ProxyState } | null
36  /** A backup of the settings before Wirepane turned the system proxy on. */
37  hasBackup: boolean
38  /** The upstream proxy Wirepane is set to use, empty for none. */
39  upstreamProxy?: string
40  /** The proxy the network service had before Wirepane took its place (an office's): host:port, socks5://host:port or pac+URL. */
41  previousProxy?: string | null
42  /** The network service's automatic proxy configuration (a PAC file), when it is on. */
43  autoProxyUrl?: string | null
44  /** The interface of the default route when it is a VPN's (utun, ppp, ipsec). */
45  vpn: string | null
46  /** Other proxy apps that run (Charles, Proxyman, mitmproxy, HTTP Toolkit). */
47  otherProxies: string[]
48  /** Who holds the port when the proxy could not take it. */
49  portHolder: string | null
50  /** The pid of a Wirepane proxy from before the shared one (0.7), when that is what holds the port. */
51  oldProxyPid?: number | null
52  android: AndroidFacts[]
53  /** Hosts passed through untouched after refusing the certificate (per client). */
54  pinned: { client: string; host: string }[]
55}
56
57const isLocal = (client: string | null) => !client || client === '127.0.0.1' || client === '::1'
58
59function clientName(client: string | null): string {
60  return isLocal(client) ? 'this Mac (a browser, a simulator, the Android emulator or a Mac app)' : `the device at ${client}`
61}
62
63function list(items: readonly string[], max = 5): string {
64  return items.length > max ? `${items.slice(0, max).join(', ')} and ${items.length - max} more` : items.join(', ')
65}
66
67const KNOWN_PROXY_PORTS: Record<number, string> = { 8888: 'Charles', 9090: 'Proxyman', 8080: 'mitmproxy or HTTP Toolkit', 8000: 'HTTP Toolkit' }
68
69function proxyFindings(facts: DoctorFacts, out: Finding[]) {
70  const { status } = facts
71  if (status.phase === 'running') out.push({ level: 'ok', title: `The proxy runs on port ${status.port}` })
72  else if (status.phase === 'failed') {
73    const busy = /EADDRINUSE|port-busy|address already in use/i.test(status.error ?? '')
74    const old = busy ? facts.oldProxyPid : null
75    out.push({
76      level: 'fail',
77      title: old
78        ? `Port ${status.port} is held by a Wirepane proxy from before 0.8 (pid ${old}), which another session may still use`
79        : busy
80          ? `Port ${status.port} is taken${facts.portHolder ? ` by ${facts.portHolder}` : ''}`
81          : 'The proxy failed to start',
82      detail: status.error ?? undefined,
83      fix: old
84        ? 'Stop it and start the shared proxy in its place; a session still on the old one gets the new one with /proxy.'
85        : busy
86          ? 'Quit what holds the port, or set another port in the plugin settings (/plugin → Wirepane → Port).'
87          : 'Start it again; the error above says why it stopped.',
88      action: old ? { kind: 'stop-old', pid: old } : { kind: 'start' },
89      label: old ? 'Stop it and start' : 'Start again',
90    })
91  } else out.push({ level: 'info', title: 'The proxy is stopped', fix: 'Start it with /proxy start or the Start button.', action: { kind: 'start' }, label: 'Start' })
92}
93
94function systemProxyFindings(facts: DoctorFacts, out: Finding[]) {
95  const sp = facts.systemProxy
96  if (!sp) return
97  const running = facts.status.phase === 'running'
98  const ours = (state: ProxyState) => state.enabled && (state.server === '127.0.0.1' || state.server === 'localhost') && state.port === facts.status.port
99  const states = [sp.web, sp.secure]
100  if (states.some(ours) && !running) {
101    out.push({
102      level: 'fail',
103      title: `${sp.service} points at Wirepane, but no proxy runs: Mac apps and the iOS Simulator have no internet`,
104      fix: facts.hasBackup ? 'Put the settings back as they were.' : 'System Settings → Network → Details → Proxies: turn the web proxies off.',
105      ...(facts.hasBackup ? { action: { kind: 'restore-system-proxy' } as DoctorAction, label: 'Put back' } : {}),
106    })
107    return
108  }
109  const elsewhere = states.find(state => state.enabled && !ours(state))
110  if (elsewhere) {
111    const who = KNOWN_PROXY_PORTS[elsewhere.port]
112    out.push({
113      level: 'warn',
114      title: `${sp.service} sends web traffic to ${elsewhere.server}:${elsewhere.port}${who ? ` (${who}'s port)` : ''}, not to Wirepane`,
115      detail: 'Mac apps and the iOS Simulator follow the system proxy, so they skip Wirepane.',
116      fix: 'Quit the other proxy, or turn the system proxy to Wirepane in Setup → macOS.',
117      action: { kind: 'setup', tab: 'cli' },
118      label: 'Setup',
119    })
120    return
121  }
122  if (states.some(ours)) out.push({ level: 'ok', title: `Mac apps and the iOS Simulator go through Wirepane (${sp.service})` })
123  else
124    out.push({
125      level: 'info',
126      title: 'The system proxy is off',
127      detail: 'Only clients pointed at Wirepane themselves are recorded: the browser it opens, the Android emulator it starts, phones set up by hand.',
128      fix: 'For Mac apps and the iOS Simulator, turn the system proxy on in Setup.',
129      action: { kind: 'setup', tab: 'ios' },
130      label: 'Setup',
131    })
132}
133
134function trustFindings(facts: DoctorFacts, out: Finding[]) {
135  if (facts.status.phase !== 'running') return
136  if (facts.macTrust === 'untrusted') {
137    out.push({
138      level: 'warn',
139      title: 'This Mac does not trust the Wirepane CA',
140      detail: 'Safari and Mac apps behind the system proxy refuse HTTPS; the browser Wirepane opens and the simulators do not need it.',
141      action: { kind: 'trust-mac' },
142      label: 'Trust on this Mac',
143    })
144  }
145  // refusals, by client: every host refused means no trust; one host among working ones means pinning
146  const byClient = new Map<string, { refused: Set<string>; decrypted: Set<string> }>()
147  for (const flow of facts.flows) {
148    const client = flow.client ?? '127.0.0.1'
149    const entry = byClient.get(client) ?? { refused: new Set(), decrypted: new Set() }
150    if (flow.errorCode === 'client-rejected-cert') entry.refused.add(flow.host)
151    else if (flow.kind !== 'tunnel' && flow.scheme === 'https' && flow.status !== null) entry.decrypted.add(flow.host)
152    byClient.set(client, entry)
153  }
154  for (const [client, { refused, decrypted }] of byClient) {
155    const pinnedHosts = [...refused].filter(host => !decrypted.has(host))
156    if (pinnedHosts.length === 0) continue
157    if (decrypted.size === 0 && refused.size >= 2) {
158      out.push({
159        level: 'fail',
160        title: `${clientName(client)} refuses the Wirepane CA for every host (${list([...refused])})`,
161        fix: isLocal(client)
162          ? 'iOS Simulator: Setup → iOS installs the CA in one press. Android emulator: Setup → Android. A Mac app: Trust on this Mac.'
163          : 'iPhone: install the profile, then Settings → General → About → Certificate Trust Settings → turn on Wirepane CA. ' +
164            "Android: install the CA as a user certificate; apps trust it only with a network_security_config (Chrome does).",
165        action: isLocal(client) ? { kind: 'setup', tab: 'ios' } : { kind: 'setup', tab: 'ios' },
166        label: 'Setup',
167      })
168    } else {
169      for (const host of pinnedHosts.slice(0, 5)) {
170        out.push({
171          level: 'warn',
172          title: `${host} refuses the Wirepane CA while other hosts work: the app pins its certificate`,
173          detail: `Seen from ${clientName(client)}. After two refusals Wirepane passes it through untouched, so the app keeps working, unrecorded.`,
174          fix: "Your own app: trust user CAs and switch pinning off in debug builds (the wirepane-troubleshooting skill shows how). Someone else's: it stays encrypted.",
175          action: { kind: 'no-decrypt', host },
176          label: 'Never decrypt it',
177        })
178      }
179    }
180  }
181}
182
183const UPSTREAM_HINTS: [RegExp, (host: string, bare: string) => Finding][] = [
184  // the upstream proxy's sign-in: one finding for the proxy, whatever the host
185  [
186    /wants Kerberos sign-in/,
187    () => ({
188      level: 'warn',
189      title: 'The upstream proxy takes only Kerberos sign-in, which Wirepane cannot do',
190      fix: "Run a helper that signs in with this Mac's ticket, such as Px (pip install px-proxy, then px), and set the upstream proxy to http://127.0.0.1:3128.",
191    }),
192  ],
193  [
194    /upstream proxy .* refused the credentials/,
195    () => ({
196      level: 'warn',
197      title: 'The upstream proxy refused the credentials',
198      fix: 'Check the user and the password in the upstream proxy setting; a Windows account goes as DOMAIN%5Cuser.',
199    }),
200  ],
201  [
202    /upstream proxy .* wants credentials/,
203    () => ({
204      level: 'warn',
205      title: 'The upstream proxy wants to know who you are (407)',
206      fix: 'Add user:password@ to the upstream proxy setting, or DOMAIN%5Cuser:password@ for a Windows sign-in: Wirepane signs in with Basic or NTLM, whichever it asks for.',
207    }),
208  ],
209  [
210    /ENOTFOUND|EAI_AGAIN/,
211    host => ({
212      level: 'warn',
213      title: `${host} does not resolve from this Mac`,
214      detail: 'The proxy looks names up on this Mac: a name only a VPN, a phone or a container knows fails here.',
215      fix: 'Check the name, connect this Mac to the VPN it needs, or map it with a rule (mapRemote).',
216    }),
217  ],
218  [
219    /DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT|ERR_TLS_CERT_ALTNAME_INVALID|CERT_HAS_EXPIRED/,
220    (host, bare) => ({
221      level: 'warn',
222      title: `${host} shows a certificate this Mac does not trust (a dev server?)`,
223      fix: 'Let Wirepane accept its certificate (for this host only).',
224      action: { kind: 'insecure-host', host: bare },
225      label: 'Accept its certificate',
226    }),
227  ],
228  [
229    /ECONNREFUSED/,
230    host => ({
231      level: 'warn',
232      title: `Nothing answers at ${host}`,
233      detail: /^(localhost|127\.|::1|10\.0\.2\.2)/.test(host)
234        ? "The proxy reaches this Mac's own ports: is the dev server running, on that port?"
235        : 'The server is down, or a firewall closes the port.',
236    }),
237  ],
238  [/ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/, host => ({ level: 'warn', title: `${host} cannot be reached from this Mac`, detail: 'A network or a VPN in the way.' })],
239]
240
241function upstreamFindings(facts: DoctorFacts, out: Finding[]) {
242  const seen = new Set<string>()
243  for (const flow of [...facts.flows].reverse()) {
244    if (flow.errorCode !== 'upstream' || !flow.error) continue
245    for (const [pattern, make] of UPSTREAM_HINTS) {
246      if (!pattern.test(flow.error)) continue
247      const finding = make(flow.port === 443 || flow.port === 80 ? flow.host : `${flow.host}:${flow.port}`, flow.host)
248      // a host's finding once per host; the upstream proxy's once
249      if (seen.has(finding.title)) break
250      seen.add(finding.title)
251      out.push({ ...finding, detail: [finding.detail, `#${flow.id}: ${flow.error}`].filter(Boolean).join(' ') })
252      break
253    }
254  }
255}
256
257function trackingFindings(facts: DoctorFacts, out: Finding[]) {
258  const { tracking, skipped } = facts
259  if (!tracking.enabled || tracking.patterns.length === 0) {
260    const hosts = new Set(facts.flows.map(flow => flow.host))
261    if (hosts.size > 15) {
262      out.push({
263        level: 'info',
264        title: `${hosts.size} hosts recorded: most of it is noise`,
265        fix: "Track only your app's domains; the rest passes through unrecorded.",
266      })
267    }
268    return
269  }
270  const recorded = facts.flows.filter(flow => flow.kind !== 'tunnel').length
271  const passing = Object.entries(skipped).sort((a, b) => b[1] - a[1])
272  if (recorded === 0 && passing.length > 0) {
273    out.push({
274      level: 'warn',
275      title: `The tracked domains (${list(tracking.patterns)}) match nothing that came`,
276      detail: `What passed through, busiest first: ${list(passing.map(([host, n]) => `${host} ×${n}`), 6)}.`,
277      fix: 'Add the app’s real hosts to the list.',
278    })
279  } else out.push({ level: 'ok', title: `Only ${list(tracking.patterns)} recorded; ${passing.length} other hosts passed through` })
280}
281
282function androidFindings(facts: DoctorFacts, out: Finding[]) {
283  const port = facts.status.port
284  for (const device of facts.android) {
285    const name = device.serial
286    const proxied = device.proxy && /:(\d+)$/.exec(device.proxy)?.[1] === String(port)
287    if (!proxied && device.serial.startsWith('emulator-')) {
288      out.push({
289        level: 'info',
290        title: `${name} does not go through Wirepane${device.proxy ? ` (its proxy is ${device.proxy})` : ''}`,
291        fix: 'Setup → Android points it at the proxy (or start the emulator from there).',
292        action: { kind: 'setup', tab: 'android' },
293        label: 'Setup',
294      })
295    }
296    const sdk = device.sdk ?? 0
297    if (device.hasSystemCa) out.push({ level: 'ok', title: `${name} trusts the CA in every app (system CA)` })
298    else if (device.isRootable && sdk >= 24) {
299      out.push({
300        level: 'info',
301        title: `${name}: apps trust user CAs only with a network_security_config`,
302        detail:
303          `This image allows root (API ${sdk}): Wirepane can put its CA among the system CAs until the next reboot, so every app trusts it.` +
304          (sdk >= 37 ? ' Android 17 also asks system CAs for Certificate Transparency, which Wirepane’s certificates do not carry: some apps may still refuse.' : ''),
305        action: { kind: 'android-system-ca', serial: device.serial },
306        label: 'Trust in all apps',
307      })
308    } else if (sdk >= 24 && device.serial.startsWith('emulator-')) {
309      out.push({
310        level: 'info',
311        title: `${name} runs a Google Play image: apps trust user CAs only with a network_security_config`,
312        detail: 'Chrome trusts the user CA; an app does only when its network_security_config says so, and this image refuses root, so the CA cannot be a system CA.',
313        fix: 'Your own app: add debug-overrides with <certificates src="user" /> (the wirepane-troubleshooting skill shows it). Every app: an emulator with a Google APIs image, then Trust in all apps.',
314      })
315    }
316  }
317}
318
319/** Every finding, the worst first. */
320export function diagnose(facts: DoctorFacts): Finding[] {
321  const out: Finding[] = []
322  proxyFindings(facts, out)
323  systemProxyFindings(facts, out)
324  if (facts.vpn) {
325    out.push({
326      level: 'warn',
327      title: `A VPN carries this Mac's traffic (${facts.vpn})`,
328      detail: 'A phone on Wi-Fi may not reach this Mac, some VPNs put back or skip the system proxy, and names may resolve only inside it.',
329      fix: 'If a phone cannot connect, try with the VPN off, or connect the phone over USB (Android: adb reverse).',
330    })
331  }
332  if (facts.previousProxy && !facts.upstreamProxy) {
333    const failing = facts.flows.filter(flow => flow.errorCode === 'upstream').length
334    out.push({
335      level: failing ? 'warn' : 'info',
336      title: `This network had its own proxy (${facts.previousProxy}), which Wirepane does not go through`,
337      detail: `An office or a VPN may let servers be reached only through it${failing ? `; ${failing} requests failed upstream` : ''}.`,
338      fix: 'Set it as the upstream proxy (add user:password@ if it asks for credentials).',
339      action: { kind: 'upstream', proxy: facts.previousProxy },
340      label: 'Use it upstream',
341    })
342  }
343  if (facts.autoProxyUrl && facts.systemProxy && [facts.systemProxy.web, facts.systemProxy.secure].some(state => state.enabled && state.port === facts.status.port)) {
344    out.push({
345      level: 'warn',
346      title: `${facts.systemProxy.service} also has automatic proxy configuration on (${facts.autoProxyUrl}), which apps may follow instead of Wirepane`,
347      fix: 'Turn "Automatic proxy configuration" off in System Settings → Network → Details → Proxies while you debug; Wirepane can use that PAC file upstream.',
348    })
349  }
350  if (facts.upstreamProxy) out.push({ level: 'info', title: `Servers are reached through the upstream proxy ${facts.upstreamProxy.replace(/\/\/[^@/]*@/, '//…@')}` })
351  if (facts.otherProxies.length) out.push({ level: 'info', title: `Other proxy apps run: ${list(facts.otherProxies)}`, detail: 'They may hold the system proxy or the same ports.' })
352  trustFindings(facts, out)
353  for (const { client, host } of facts.pinned.slice(0, 8)) {
354    out.push({ level: 'info', title: `${host} passes through untouched for ${clientName(client)}`, detail: 'It refused the certificate twice; its traffic flows, unrecorded.' })
355  }
356  upstreamFindings(facts, out)
357  trackingFindings(facts, out)
358  androidFindings(facts, out)
359  if (facts.status.phase === 'running' && facts.flows.length === 0) {
360    out.push({
361      level: 'info',
362      title: 'Nothing recorded yet',
363      fix: 'Point a client at the proxy (Setup), then use the app. An app that ignores the system proxy (Flutter, some games, Go and Node tools) needs its own setting.',
364    })
365  }
366  const rank: Record<FindingLevel, number> = { fail: 0, warn: 1, info: 2, ok: 3 }
367  return out.map((finding, i) => ({ finding, i })).sort((a, b) => rank[a.finding.level] - rank[b.finding.level] || a.i - b.i).map(({ finding }) => finding)
368}
369
370const MARKS: Record<FindingLevel, string> = { fail: '✗', warn: '!', info: '•', ok: '✓' }
371
372/** The findings as text for Claude or a command's answer. */
373export function findingsText(findings: readonly (Omit<Finding, 'action'> & { action?: unknown })[]): string {
374  return findings
375    .map(f => [`${MARKS[f.level]} ${f.title}`, f.detail ? `    ${f.detail}` : '', f.fix ? `    fix: ${f.fix}` : '', f.action ? `    (the Health view has a button: ${f.label})` : ''].filter(Boolean).join('\n'))
376    .join('\n')
377}
378
hooks/har.ts 79 lines
1// Captured exchanges as HAR 1.2, the format browsers' DevTools, Charles,
2// Proxyman and HTTP Toolkit read.
3
4import type { Exchange } from './diff'
5import type { WsRecord } from './flows'
6
7const OPCODES: Record<string, number> = { text: 1, binary: 2, close: 8, ping: 9, pong: 10 }
8
9function version(httpVersion: string | undefined): string {
10  return httpVersion === '2' ? 'HTTP/2' : `HTTP/${httpVersion ?? '1.1'}`
11}
12
13function queryOf(url: URL): { name: string; value: string }[] {
14  const out: { name: string; value: string }[] = []
15  url.searchParams.forEach((value, name) => out.push({ name, value }))
16  return out
17}
18
19function headers(list: readonly [string, string][]) {
20  return list.map(([name, value]) => ({ name, value }))
21}
22
23function header(list: readonly [string, string][], name: string): string | undefined {
24  return list.find(([key]) => key.toLowerCase() === name)?.[1]
25}
26
27export function toHar(exchanges: readonly (Exchange & { messages?: WsRecord[] })[], wirepaneVersion: string) {
28  return {
29    log: {
30      version: '1.2',
31      creator: { name: 'Wirepane', version: wirepaneVersion },
32      entries: exchanges.map(({ detail, reqText, resText, messages }) => {
33        const url = new URL(detail.url)
34        const time = detail.durationMs ?? 0
35        return {
36          startedDateTime: new Date(detail.ts).toISOString(),
37          time,
38          request: {
39            method: detail.method,
40            url: detail.url,
41            httpVersion: version(detail.httpVersion),
42            cookies: [],
43            headers: headers(detail.reqHeaders),
44            queryString: queryOf(url),
45            ...(reqText !== null ? { postData: { mimeType: header(detail.reqHeaders, 'content-type') ?? 'application/octet-stream', text: reqText } } : {}),
46            headersSize: -1,
47            bodySize: detail.reqSize,
48          },
49          response: {
50            status: detail.status ?? 0,
51            statusText: detail.statusMessage ?? '',
52            httpVersion: version(detail.upstreamHttpVersion ?? detail.httpVersion),
53            cookies: [],
54            headers: headers(detail.resHeaders),
55            content: { size: detail.resSize, mimeType: detail.contentType ?? 'x-unknown', ...(resText !== null ? { text: resText } : {}) },
56            redirectURL: header(detail.resHeaders, 'location') ?? '',
57            headersSize: -1,
58            bodySize: detail.resSize,
59          },
60          cache: {},
61          timings: { send: 0, wait: time, receive: 0 },
62          _wirepane: { id: detail.id, client: detail.client, rules: detail.rules ?? [], error: detail.error },
63          ...(messages
64            ? {
65                _resourceType: 'websocket',
66                _webSocketMessages: messages.map(m => ({
67                  type: m.dir === 'out' ? 'send' : 'receive',
68                  time: (detail.ts + m.t) / 1000,
69                  opcode: OPCODES[m.op] ?? 0,
70                  data: m.text ?? m.b64 ?? '',
71                })),
72              }
73            : {}),
74        }
75      }),
76    },
77  }
78}
79
hooks/model.ts 71 lines
1// Pure logic for what the tools show Claude: as much as helps, in as few
2// tokens as it takes. URLs cut at the query, long header values cut, one
3// budget shared by the bodies, JSON compact when pretty would not fit.
4
5import type { ProxyFlow } from '../types'
6
7export { modelUrl } from './flows'
8
9/** A value cut at `max` characters, saying how long it was. */
10export function clipValue(value: string, max: number): string {
11  return value.length <= max ? value : `${value.slice(0, max)}…(${value.length} chars)`
12}
13
14/** Shares `total` characters among parts of these lengths: a short part keeps all it needs, the rest is split evenly. */
15export function splitBudget(lengths: readonly number[], total: number): number[] {
16  const out = lengths.map(() => 0)
17  const order = lengths.map((length, i) => ({ length, i })).sort((a, b) => a.length - b.length)
18  let left = total
19  order.forEach(({ length, i }, k) => {
20    const share = Math.floor(left / (order.length - k))
21    out[i] = Math.min(length, share)
22    left -= out[i]!
23  })
24  return out
25}
26
27function isJsonType(contentType: string | null, text: string): boolean {
28  return (contentType ?? '').includes('json') || /^\s*[[{]/.test(text)
29}
30
31/** A body in at most `budget` characters: JSON pretty when it fits, compact when not, then cut. */
32export function bodyForModel(text: string, contentType: string | null, budget: number): { text: string; isCut: boolean } {
33  if (isJsonType(contentType, text)) {
34    try {
35      const value: unknown = JSON.parse(text)
36      const pretty = JSON.stringify(value, null, 2)
37      if (pretty.length <= budget) return { text: pretty, isCut: false }
38      const compact = JSON.stringify(value)
39      return compact.length <= budget ? { text: compact, isCut: false } : { text: compact.slice(0, budget), isCut: true }
40    } catch {}
41  }
42  return text.length <= budget ? { text, isCut: false } : { text: text.slice(0, budget), isCut: true }
43}
44
45/** One part of a JSON value by a path like `data.items[0].name` (a leading `$.` is fine). */
46export function jsonPath(value: unknown, path: string): { value: unknown } | { error: string } {
47  const tokens = path
48    .trim()
49    .replace(/^\$\.?/, '')
50    .replace(/\[(\d+)\]/g, '.$1')
51    .split('.')
52    .filter(Boolean)
53  let current: unknown = value
54  const walked: string[] = []
55  for (const token of tokens) {
56    if (current === null || typeof current !== 'object') {
57      return { error: `${walked.join('.') || 'the body'} has no ${token}` }
58    }
59    current = (current as Record<string, unknown>)[token]
60    walked.push(token)
61  }
62  return current === undefined ? { error: `${walked.join('.')} is not in the body` } : { value: current }
63}
64
65/** The hosts with the most requests, most first. */
66export function busiestHosts(flows: readonly ProxyFlow[], n: number): [string, number][] {
67  const counts = new Map<string, number>()
68  for (const flow of flows) counts.set(flow.host, (counts.get(flow.host) ?? 0) + 1)
69  return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n)
70}
71
hooks/qr.ts 332 lines
1// A QR code encoder for short text, so a phone can scan the proxy's setup
2// address: byte mode, error correction level M, versions 1 to 10, the mask
3// chosen by the standard's penalty rules (ISO/IEC 18004). A mod ships no
4// dependencies, hence this file; it follows the structure of Project Nayuki's
5// reference encoder.
6
7export type QrCode = {
8  /** Modules per side, the quiet zone not included. */
9  size: number
10  /** `modules[y][x]`: true for a dark module. */
11  modules: boolean[][]
12}
13
14const MAX_VERSION = 10
15// per version 1..10, error correction level M
16const ECC_PER_BLOCK = [10, 16, 26, 18, 24, 16, 18, 22, 22, 26]
17const BLOCKS = [1, 1, 1, 2, 2, 4, 4, 4, 5, 5]
18const FORMAT_BITS_M = 0
19
20function rawDataModules(version: number): number {
21  let result = (16 * version + 128) * version + 64
22  if (version >= 2) {
23    const aligns = Math.floor(version / 7) + 2
24    result -= (25 * aligns - 10) * aligns - 55
25    if (version >= 7) result -= 36
26  }
27  return result
28}
29
30function dataCodewords(version: number): number {
31  return Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[version - 1]! * BLOCKS[version - 1]!
32}
33
34function gfMultiply(x: number, y: number): number {
35  let z = 0
36  for (let i = 7; i >= 0; i--) {
37    z = (z << 1) ^ ((z >>> 7) * 0x11d)
38    z ^= ((y >>> i) & 1) * x
39  }
40  return z
41}
42
43function rsDivisor(degree: number): number[] {
44  const result = new Array<number>(degree).fill(0)
45  result[degree - 1] = 1
46  let root = 1
47  for (let i = 0; i < degree; i++) {
48    for (let j = 0; j < result.length; j++) {
49      result[j] = gfMultiply(result[j]!, root)
50      if (j + 1 < result.length) result[j]! ^= result[j + 1]!
51    }
52    root = gfMultiply(root, 0x02)
53  }
54  return result
55}
56
57function rsRemainder(data: readonly number[], divisor: readonly number[]): number[] {
58  const result = divisor.map(() => 0)
59  for (const byte of data) {
60    const factor = byte ^ result.shift()!
61    result.push(0)
62    divisor.forEach((coefficient, i) => {
63      result[i]! ^= gfMultiply(coefficient, factor)
64    })
65  }
66  return result
67}
68
69/** Splits the data into blocks, adds each block's error correction, interleaves. */
70function withErrorCorrection(data: readonly number[], version: number): number[] {
71  const blockCount = BLOCKS[version - 1]!
72  const eccLength = ECC_PER_BLOCK[version - 1]!
73  const raw = Math.floor(rawDataModules(version) / 8)
74  const shortBlocks = blockCount - (raw % blockCount)
75  const shortLength = Math.floor(raw / blockCount)
76  const divisor = rsDivisor(eccLength)
77  const blocks: number[][] = []
78  for (let i = 0, k = 0; i < blockCount; i++) {
79    const block = data.slice(k, k + shortLength - eccLength + (i < shortBlocks ? 0 : 1))
80    k += block.length
81    const ecc = rsRemainder(block, divisor)
82    if (i < shortBlocks) block.push(0)
83    blocks.push([...block, ...ecc])
84  }
85  const result: number[] = []
86  for (let i = 0; i < blocks[0]!.length; i++) {
87    blocks.forEach((block, j) => {
88      // the padding byte of a short block is not sent
89      if (i !== shortLength - eccLength || j >= shortBlocks) result.push(block[i]!)
90    })
91  }
92  return result
93}
94
95function alignmentPositions(version: number, size: number): number[] {
96  if (version === 1) return []
97  const count = Math.floor(version / 7) + 2
98  const step = Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2
99  const result = [6]
100  for (let position = size - 7; result.length < count; position -= step) result.splice(1, 0, position)
101  return result
102}
103
104function masked(mask: number, x: number, y: number): boolean {
105  switch (mask) {
106    case 0: return (x + y) % 2 === 0
107    case 1: return y % 2 === 0
108    case 2: return x % 3 === 0
109    case 3: return (x + y) % 3 === 0
110    case 4: return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0
111    case 5: return ((x * y) % 2) + ((x * y) % 3) === 0
112    case 6: return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0
113    default: return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
114  }
115}
116
117function penalty(modules: boolean[][]): number {
118  const size = modules.length
119  let score = 0
120  const lines: string[] = []
121  for (let y = 0; y < size; y++) lines.push(modules[y]!.map(dark => (dark ? '1' : '0')).join(''))
122  for (let x = 0; x < size; x++) lines.push(modules.map(row => (row[x] ? '1' : '0')).join(''))
123  for (const line of lines) {
124    // runs of five or more of one color
125    for (const run of line.match(/0{5,}|1{5,}/g) ?? []) score += 3 + run.length - 5
126    // a pattern that looks like a finder, with light on either side
127    const padded = `0000${line}0000`
128    for (let i = 0; i + 11 <= padded.length; i++) {
129      const window = padded.slice(i, i + 11)
130      if (window === '10111010000' || window === '00001011101') score += 40
131    }
132  }
133  // 2x2 blocks of one color
134  for (let y = 0; y + 1 < size; y++) {
135    for (let x = 0; x + 1 < size; x++) {
136      const color = modules[y]![x]
137      if (color === modules[y]![x + 1] && color === modules[y + 1]![x] && color === modules[y + 1]![x + 1]) score += 3
138    }
139  }
140  // balance of dark and light
141  const dark = modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0)
142  const total = size * size
143  score += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10
144  return score
145}
146
147export function encodeQr(text: string): QrCode {
148  const bytes = [...new TextEncoder().encode(text)]
149  let version = 1
150  while (version <= MAX_VERSION && 4 + (version <= 9 ? 8 : 16) + bytes.length * 8 > dataCodewords(version) * 8) version++
151  if (version > MAX_VERSION) throw new Error(`${bytes.length} bytes do not fit a version ${MAX_VERSION} QR code`)
152
153  const bits: number[] = []
154  const push = (value: number, length: number) => {
155    for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1)
156  }
157  const capacity = dataCodewords(version) * 8
158  push(0b0100, 4) // byte mode
159  push(bytes.length, version <= 9 ? 8 : 16)
160  for (const byte of bytes) push(byte, 8)
161  push(0, Math.min(4, capacity - bits.length))
162  push(0, (8 - (bits.length % 8)) % 8)
163  for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) push(pad, 8)
164  const data: number[] = []
165  for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((byte, bit) => (byte << 1) | bit, 0))
166  const codewords = withErrorCorrection(data, version)
167
168  const size = version * 4 + 17
169  const modules = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
170  const isFunction = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
171  const set = (x: number, y: number, dark: boolean) => {
172    modules[y]![x] = dark
173    isFunction[y]![x] = true
174  }
175
176  for (let i = 0; i < size; i++) {
177    set(6, i, i % 2 === 0)
178    set(i, 6, i % 2 === 0)
179  }
180  for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]] as const) {
181    for (let dy = -4; dy <= 4; dy++) {
182      for (let dx = -4; dx <= 4; dx++) {
183        const x = cx + dx
184        const y = cy + dy
185        const distance = Math.max(Math.abs(dx), Math.abs(dy))
186        if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, distance !== 2 && distance !== 4)
187      }
188    }
189  }
190  const aligns = alignmentPositions(version, size)
191  for (let i = 0; i < aligns.length; i++) {
192    for (let j = 0; j < aligns.length; j++) {
193      const isFinderCorner = (i === 0 && j === 0) || (i === 0 && j === aligns.length - 1) || (i === aligns.length - 1 && j === 0)
194      if (isFinderCorner) continue
195      for (let dy = -2; dy <= 2; dy++) {
196        for (let dx = -2; dx <= 2; dx++) set(aligns[i]! + dx, aligns[j]! + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
197      }
198    }
199  }
200
201  const drawFormat = (mask: number) => {
202    const value = (FORMAT_BITS_M << 3) | mask
203    let rem = value
204    for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
205    const formatBits = ((value << 10) | rem) ^ 0x5412
206    const bit = (i: number) => ((formatBits >>> i) & 1) !== 0
207    for (let i = 0; i <= 5; i++) set(8, i, bit(i))
208    set(8, 7, bit(6))
209    set(8, 8, bit(7))
210    set(7, 8, bit(8))
211    for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i))
212    for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i))
213    for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i))
214    set(8, size - 8, true)
215  }
216  drawFormat(0)
217
218  if (version >= 7) {
219    let rem = version
220    for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
221    const versionBits = (version << 12) | rem
222    for (let i = 0; i < 18; i++) {
223      const dark = ((versionBits >>> i) & 1) !== 0
224      const a = size - 11 + (i % 3)
225      const b = Math.floor(i / 3)
226      set(a, b, dark)
227      set(b, a, dark)
228    }
229  }
230
231  // the data, two columns at a time, zigzagging up and down from the right
232  let index = 0
233  for (let right = size - 1; right >= 1; right -= 2) {
234    if (right === 6) right = 5
235    for (let vertical = 0; vertical < size; vertical++) {
236      for (let j = 0; j < 2; j++) {
237        const x = right - j
238        const isUpward = ((right + 1) & 2) === 0
239        const y = isUpward ? size - 1 - vertical : vertical
240        if (!isFunction[y]![x] && index < codewords.length * 8) {
241          modules[y]![x] = ((codewords[index >>> 3]! >>> (7 - (index & 7))) & 1) === 1
242          index++
243        }
244      }
245    }
246  }
247
248  const applyMask = (mask: number) => {
249    for (let y = 0; y < size; y++) {
250      for (let x = 0; x < size; x++) {
251        if (!isFunction[y]![x] && masked(mask, x, y)) modules[y]![x] = !modules[y]![x]
252      }
253    }
254  }
255  let best = 0
256  let bestScore = Infinity
257  for (let mask = 0; mask < 8; mask++) {
258    applyMask(mask)
259    drawFormat(mask)
260    const score = penalty(modules)
261    if (score < bestScore) {
262      best = mask
263      bestScore = score
264    }
265    applyMask(mask)
266  }
267  applyMask(best)
268  drawFormat(best)
269  return { size, modules }
270}
271
272const DARK = 0x000000
273const LIGHT = 0xffffff
274const UPPER_HALF = 0x2580
275
276/** The module at (x, y), with `quiet` light modules around the code. */
277function moduleAt(qr: QrCode, quiet: number, x: number, y: number): boolean {
278  const mx = x - quiet
279  const my = y - quiet
280  return mx >= 0 && my >= 0 && mx < qr.size && my < qr.size && qr.modules[my]![mx]!
281}
282
283const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
284
285export function toBase64(bytes: Uint8Array): string {
286  let out = ''
287  for (let i = 0; i < bytes.length; i += 3) {
288    const a = bytes[i]!
289    const b = bytes[i + 1]
290    const c = bytes[i + 2]
291    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
292    out += BASE64[(n >>> 18) & 63]! + BASE64[(n >>> 12) & 63]!
293    out += b === undefined ? '=' : BASE64[(n >>> 6) & 63]!
294    out += c === undefined ? '=' : BASE64[n & 63]!
295  }
296  return out
297}
298
299/**
300 * The code as a terminal Raster: one cell holds two modules, the upper half
301 * block painted with the top one and its background with the bottom one, in
302 * black and white whatever the terminal's theme, so the modules come out
303 * square and the right way round.
304 */
305export function qrRaster(qr: QrCode, quiet = 2): { columns: number; rows: number; cells: string } {
306  const columns = qr.size + quiet * 2
307  const rows = Math.ceil(columns / 2)
308  const words = new Uint32Array(columns * rows * 3)
309  for (let row = 0; row < rows; row++) {
310    for (let x = 0; x < columns; x++) {
311      const at = (row * columns + x) * 3
312      words[at] = UPPER_HALF
313      words[at + 1] = moduleAt(qr, quiet, x, row * 2) ? DARK : LIGHT
314      words[at + 2] = moduleAt(qr, quiet, x, row * 2 + 1) ? DARK : LIGHT
315    }
316  }
317  // Uint32Array stores in the platform's order; every engine this runs on is little-endian
318  return { columns, rows, cells: toBase64(new Uint8Array(words.buffer)) }
319}
320
321/** The code as SVG markup, dark modules on a white square. */
322export function qrSvg(qr: QrCode, quiet = 4): string {
323  const side = qr.size + quiet * 2
324  const path: string[] = []
325  for (let y = 0; y < qr.size; y++) {
326    for (let x = 0; x < qr.size; x++) {
327      if (qr.modules[y]![x]) path.push(`M${x + quiet} ${y + quiet}h1v1h-1z`)
328    }
329  }
330  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${side} ${side}" shape-rendering="crispEdges"><rect width="${side}" height="${side}" fill="#fff"/><path d="${path.join('')}" fill="#000"/></svg>`
331}
332
hooks/setup.ts 304 lines
1// What the setup tabs say and the commands they copy or run.
2
3import type { ProxyAddress, ProxySetupTab, ProxyStatus } from '../types'
4
5export const MAGIC_HOST = 'claude.proxy'
6
7export const SETUP_TABS: { tab: ProxySetupTab; label: string; hotkey: string }[] = [
8  { tab: 'browser', label: 'Browser', hotkey: '1' },
9  { tab: 'ios', label: 'iOS', hotkey: '2' },
10  { tab: 'android', label: 'Android', hotkey: '3' },
11  { tab: 'cli', label: 'macOS / CLI', hotkey: '4' },
12]
13
14export type Browser = { name: string; app: string; slug: string }
15
16/** Chromium browsers that honour --proxy-server and the SPKI allow-list. */
17export const BROWSER_CANDIDATES: Browser[] = [
18  { name: 'Google Chrome', app: 'Google Chrome.app', slug: 'chrome' },
19  { name: 'Chrome Canary', app: 'Google Chrome Canary.app', slug: 'chrome-canary' },
20  { name: 'Chromium', app: 'Chromium.app', slug: 'chromium' },
21  { name: 'Microsoft Edge', app: 'Microsoft Edge.app', slug: 'edge' },
22  { name: 'Brave', app: 'Brave Browser.app', slug: 'brave' },
23]
24
25export type SetupFacts = {
26  status: ProxyStatus
27  dataDir: string
28  listen: 'local' | 'lan'
29}
30
31function proxyAddress(status: ProxyStatus): string {
32  return `127.0.0.1:${status.port}`
33}
34
35/** The address a phone on the same network enters as its proxy server. */
36export function phoneAddress(facts: SetupFacts): ProxyAddress | null {
37  return facts.status.lan.find(entry => entry.isPrimary) ?? null
38}
39
40/** One client of a tab and exactly what goes into its proxy settings. */
41export type ProxyTarget = {
42  client: string
43  /** What to enter as the server; null when this Mac has no address for it. */
44  host: string | null
45  port: number
46  /** Where it goes, or what sets it. */
47  how: string
48  /** Reached over the network: needs Proxy reachable from = lan. */
49  isRemote: boolean
50}
51
52export function proxyTargets(facts: SetupFacts, tab: ProxySetupTab): ProxyTarget[] {
53  const port = facts.status.port
54  const phone = phoneAddress(facts)?.address ?? null
55  switch (tab) {
56    case 'browser':
57      return [{ client: 'Separate browser', host: '127.0.0.1', port, how: 'set by the Open button', isRemote: false }]
58    case 'ios':
59      return [
60        { client: 'iOS Simulator', host: '127.0.0.1', port, how: 'macOS system proxy ("system proxy on")', isRemote: false },
61        { client: 'iPhone / iPad', host: phone, port, how: 'Wi-Fi → (i) → Configure Proxy → Manual', isRemote: true },
62      ]
63    case 'android':
64      return [
65        { client: 'Android emulator', host: '10.0.2.2', port, how: 'set by "Android → proxy"', isRemote: false },
66        { client: 'Android on USB', host: '127.0.0.1', port, how: 'set by "Android → proxy" (adb reverse)', isRemote: false },
67        { client: 'Android on Wi-Fi', host: phone, port, how: 'Wi-Fi → Modify → Proxy: Manual', isRemote: true },
68      ]
69    default:
70      return [{ client: 'curl, scripts, system proxy', host: '127.0.0.1', port, how: 'HTTPS_PROXY / -x / networksetup', isRemote: false }]
71  }
72}
73
74/** What the phone steps say before step 1 when the phone cannot reach the proxy yet. */
75function phonePreface(facts: SetupFacts): string {
76  const phone = phoneAddress(facts)
77  if (!phone) {
78    return facts.status.lan.length === 0 && facts.status.phase !== 'running'
79      ? 'Start the proxy to find the address the phone should use.\n\n'
80      : 'This Mac has no address a phone could reach: connect it to Wi-Fi or Ethernet, on the same network as the phone.\n\n'
81  }
82  return facts.listen === 'lan'
83    ? ''
84    : 'First turn on **Listen on LAN** (the button above): the proxy listens on this Mac only now, so the phone cannot reach it.\n\n'
85}
86
87export function browserArgs(facts: SetupFacts, browser: Browser): string[] {
88  const spki = facts.status.ca?.spki ?? []
89  return [
90    `--user-data-dir=${facts.dataDir}/browser/${browser.slug}`,
91    `--proxy-server=http://${proxyAddress(facts.status)}`,
92    // Chrome skips the proxy for localhost unless told otherwise.
93    '--proxy-bypass-list=<-loopback>',
94    `--ignore-certificate-errors-spki-list=${spki.join(',')}`,
95    '--no-first-run',
96    '--no-default-browser-check',
97    `http://${MAGIC_HOST}/`,
98  ]
99}
100
101export function shellQuote(text: string): string {
102  return /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
103}
104
105export function browserCommand(facts: SetupFacts, browser: Browser, appPath: string): string {
106  return ['open', '-na', appPath, '--args', ...browserArgs(facts, browser)].map(shellQuote).join(' ')
107}
108
109export function systemProxyCommands(port: number, service = 'Wi-Fi'): { on: string; off: string } {
110  const s = shellQuote(service)
111  return {
112    on: `networksetup -setwebproxy ${s} 127.0.0.1 ${port} && networksetup -setsecurewebproxy ${s} 127.0.0.1 ${port}`,
113    off: `networksetup -setwebproxystate ${s} off && networksetup -setsecurewebproxystate ${s} off`,
114  }
115}
116
117export function trustCommand(caPath: string): string {
118  return `security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ${shellQuote(caPath)}`
119}
120
121export function untrustCommand(caPath: string): string {
122  return `security remove-trusted-cert ${shellQuote(caPath)}`
123}
124
125export const ANDROID_NETWORK_CONFIG = `<!-- res/xml/network_security_config.xml -->
126<network-security-config>
127  <debug-overrides>
128    <trust-anchors>
129      <certificates src="user" />
130    </trust-anchors>
131  </debug-overrides>
132</network-security-config>
133
134<!-- AndroidManifest.xml, on <application> -->
135android:networkSecurityConfig="@xml/network_security_config"`
136
137/** The copyable commands each tab offers, by key. */
138export function setupCommands(facts: SetupFacts): Record<string, string> {
139  const port = facts.status.port
140  const ca = facts.status.ca?.path ?? `${facts.dataDir}/ca/ca.pem`
141  const proxy = systemProxyCommands(port)
142  return {
143    'sys-on': proxy.on,
144    'sys-off': proxy.off,
145    trust: trustCommand(ca),
146    untrust: untrustCommand(ca),
147    'sim-ca': `xcrun simctl keychain booted add-root-cert ${shellQuote(ca)}`,
148    'adb-on': `adb shell settings put global http_proxy 10.0.2.2:${port}`,
149    'adb-off': 'adb shell settings put global http_proxy :0',
150    'android-config': ANDROID_NETWORK_CONFIG,
151    curl: `curl -x http://${proxyAddress(facts.status)} --cacert ${shellQuote(ca)} https://example.com/`,
152    env: [
153      `export HTTPS_PROXY=http://${proxyAddress(facts.status)} HTTP_PROXY=http://${proxyAddress(facts.status)}`,
154      `export NODE_EXTRA_CA_CERTS=${shellQuote(ca)} SSL_CERT_FILE=${shellQuote(ca)} REQUESTS_CA_BUNDLE=${shellQuote(ca)}`,
155    ].join('\n'),
156  }
157}
158
159export function browserGuide(facts: SetupFacts, found: readonly Browser[]): string {
160  const browsers = found.length > 0 ? found.map(b => b.name).join(', ') : 'none (looked for Chrome, Chromium, Edge, Brave)'
161  return `### A separate browser whose traffic all goes through the proxy
162
163The button below starts a **new instance** of a Chromium browser with a profile of its own
164(\`${facts.dataDir}/browser/…\`) that:
165
166- goes through the proxy \`${proxyAddress(facts.status)}\`, **localhost included** (\`--proxy-bypass-list=<-loopback>\`);
167- trusts the proxy's certificates by the SPKI hash of their key, so **nothing goes into the keychain**;
168- leaves your everyday profile alone: logins, extensions and tabs stay apart, and the profile is kept between runs.
169
170Found: ${browsers}.
171
172It opens on \`http://${MAGIC_HOST}/\`: if that page shows, the browser is going through the proxy.
173A yellow "unsupported command-line flag" bar is expected; it is how Chrome flags the trust switch.
174
175**Firefox** (by hand): a separate profile (\`firefox -P\`), then Settings → Network Settings → Manual proxy
176configuration, \`127.0.0.1\` port \`${facts.status.port}\` for HTTP and HTTPS. In \`about:config\` set
177\`network.proxy.allow_hijacking_localhost = true\`. Import the CA under Settings → Privacy & Security → Certificates →
178View Certificates → Authorities → Import (\`${facts.status.ca?.path ?? 'ca.pem'}\`) and tick "Trust this CA to identify websites".`
179}
180
181export function iosGuide(facts: SetupFacts): string {
182  const phone = phoneAddress(facts)
183  const device = !phone
184    ? phonePreface(facts)
185    : `${phonePreface(facts)}The iPhone must be on the same network as this Mac's ${phone.label}.
186
1871. **Scan the QR code above** with the Camera app (or open \`http://${phone.address}:${facts.status.port}/\` in Safari)
188   → "iOS: download profile" → Allow. This page needs no proxy setting yet.
1892. **Settings → Profile Downloaded → Install** (the profile is unsigned; that is expected).
1903. **Settings → General → About → Certificate Trust Settings** → turn on "${caName(facts)}".
1914. **Settings → Wi-Fi → (i) next to the network → Configure Proxy → Manual**:
192   Server \`${phone.address}\`, Port \`${facts.status.port}\`, Authentication off.
1935. Done. Rows marked **CERT** in the list mean step 3 is missing or the app pins its certificates;
194   such hosts can go into the "Hosts never decrypted" option.
195
196When you are done, set **Configure Proxy → Off** again, or the iPhone loses its network once the proxy stops.`
197  return `### iOS Simulator
198
199**Use** next to a simulator does it all: boots it, adds the proxy's root certificate
200(\`xcrun simctl keychain <udid> add-root-cert\`) and turns the **macOS system proxy** on, since the
201simulator has no proxy setting of its own. Every app on this Mac then goes through the proxy, Claude
202Code excepted: its hosts bypass it and its own connections are tunnelled, never decrypted. The system
203proxy is put back when the proxy stops, when the session ends, or if Claude Code quits.
204Turn on the tracking list (**Domains**, \`d\`) to record only your app's hosts.
205
206### iPhone / iPad
207
208${device}`
209}
210
211export function androidGuide(facts: SetupFacts): string {
212  const phone = phoneAddress(facts)
213  const device = !phone
214    ? phonePreface(facts)
215    : `${phonePreface(facts)}The phone must be on the same network as this Mac's ${phone.label}.
216
2171. **Scan the QR code above** with the camera (or open \`http://${phone.address}:${facts.status.port}/\` in Chrome)
218   → "Android: download certificate". This page needs no proxy setting yet.
2192. **Settings → Security → Encryption & credentials → Install a certificate → CA certificate** → pick the downloaded file.
2203. **Settings → Wi-Fi → long-press the network → Modify → Advanced → Proxy: Manual**:
221   Hostname \`${phone.address}\`, Port \`${facts.status.port}\`.
2224. When you are done, set **Proxy: None** again.
223
224A phone on USB needs none of this: "Android → proxy" reaches it through \`adb reverse\`.`
225  return `### Android emulator and USB devices
226
2271. **"Android → proxy"** points every device \`adb devices\` lists at the proxy: an emulator at
228   \`10.0.2.2:${facts.status.port}\` (an emulator's name for this Mac's localhost), a USB device at
229   \`127.0.0.1:${facts.status.port}\` through \`adb reverse\`. The \`local\` mode is enough for both.
230   **"Revert"** sets \`:0\` again; the mod also reverts by itself when the proxy stops and when the session ends.
2312. **"Open CA page"** opens \`http://${MAGIC_HOST}/\` in the device's browser. Download "Android: download certificate",
232   then **Settings → Security → Encryption & credentials → Install a certificate → CA certificate**.
233
234### Android over Wi-Fi
235
236${device}
237
238### Apps and user CAs
239
240Since Android 7, apps **do not trust** certificates the user installed. Chrome does; your app will show
241**CERT** rows. Give its debug build a \`network_security_config\` ("config snippet"):
242
243\`\`\`xml
244${ANDROID_NETWORK_CONFIG}
245\`\`\``
246}
247
248export function cliGuide(facts: SetupFacts): string {
249  const ca = facts.status.ca?.path ?? 'ca.pem'
250  return `### Trusting the CA on this Mac
251
252Needed only when traffic does not come from the separate browser, for example from Safari or through the system proxy:
253
254\`\`\`sh
255${trustCommand(ca)}
256\`\`\`
257
258To undo: \`${untrustCommand(ca)}\`.
259
260### The macOS system proxy (Safari, the simulator, native apps)
261
262\`\`\`sh
263${systemProxyCommands(facts.status.port).on}
264# turn it off:
265${systemProxyCommands(facts.status.port).off}
266\`\`\`
267
268\`networksetup -listallnetworkservices\` lists the service names (\`Wi-Fi\` here). If the proxy stops while
269the system proxy is on, the Mac has no network until it is turned off.
270
271### curl and scripts
272
273\`\`\`sh
274${setupCommands(facts).curl}
275${setupCommands(facts).env}
276\`\`\``
277}
278
279export function caName(facts: SetupFacts): string {
280  return facts.status.ca?.subject.match(/CN=([^,]+)/)?.[1] ?? 'Wirepane CA'
281}
282
283/** The oldest Node the sidecar runs on. */
284export const MIN_NODE = 18
285
286/** Where to get Node.js when this Mac has no Homebrew. */
287export const NODE_DOWNLOAD = 'https://nodejs.org/en/download'
288
289/** The major version in `node --version` output ("v22.11.0" → 22), 0 for anything else. */
290export function nodeMajor(output: string): number {
291  return Number(/^v(\d+)\./.exec(output.trim())?.[1] ?? 0)
292}
293
294/** A version manager's folders ("v22.11.0", …), the newest first; other names left out. */
295export function newestNodeFirst(names: readonly string[]): string[] {
296  const parts = (name: string) => name.slice(1).split('.').map(Number)
297  return names
298    .filter(name => /^v\d+\.\d+\.\d+$/.test(name))
299    .sort((a, b) => {
300      const [x, y] = [parts(a), parts(b)]
301      return y[0]! - x[0]! || y[1]! - x[1]! || y[2]! - x[2]!
302    })
303}
304
types/index.d.ts 201 lines
1export type ProxyFlow = {
2  id: number
3  ts: number
4  kind: 'http' | 'ws' | 'tunnel'
5  method: string
6  scheme: 'http' | 'https'
7  host: string
8  port: number
9  path: string
10  status: number | null
11  reqSize: number
12  resSize: number
13  durationMs: number | null
14  contentType: string | null
15  state: 'pending' | 'receiving' | 'done' | 'error'
16  error: string | null
17  errorCode: string | null
18  client: string | null
19  note?: string
20  /** The ids of the rules that changed this exchange, in the order they acted. */
21  rules?: string[]
22  /** The client's protocol: '2' for HTTP/2, else '1.1' or '1.0'. */
23  httpVersion?: string
24  /** A gRPC call's status from its trailers (0 is OK), and its message. */
25  grpcStatus?: number
26  grpcMessage?: string
27  /** A WebSocket's text and binary messages so far: client to server, server to client. */
28  wsOut?: number
29  wsIn?: number
30  /** A text/event-stream response's events so far. */
31  sseEvents?: number
32  /** The request this one sent again (replay_request). */
33  replayOf?: number | null
34  /** Held at a rule's breakpoint, before it is sent (request) or before the client gets the answer (response). */
35  held?: 'request' | 'response'
36}
37
38/** An exchange held at a breakpoint, as the proxy shows it. */
39export type ProxyHeld = {
40  id: number
41  phase: 'request' | 'response'
42  since: number
43  view: { method: string; url: string; status?: number; headers: [string, string][]; body: string | null }
44}
45
46export type ProxyCa = {
47  path: string
48  subject: string
49  fingerprint256: string
50  validTo: string
51  spki: string[]
52}
53
54/** One of this machine's addresses, as the sidecar ranks them for a phone. */
55export type ProxyAddress = {
56  address: string
57  iface: string
58  label: string
59  kind: 'lan' | 'vpn' | 'virtual'
60  isPrimary: boolean
61}
62
63export type ProxyPhase = 'stopped' | 'starting' | 'running' | 'failed'
64
65export type ProxyStatus = {
66  phase: ProxyPhase
67  host: string
68  port: number
69  addresses: string[]
70  lan: ProxyAddress[]
71  runDir: string | null
72  pid: number | null
73  ca: ProxyCa | null
74  /** The sidecar's token for its control commands (live WebSockets). */
75  control?: { token: string } | null
76  /** One proxy for every session on this Mac (it outlives the session that started it). */
77  isShared?: boolean
78  /** The running proxy's Wirepane version (another session may have started an older one). */
79  proxyVersion?: string
80  error: string | null
81  /** No Node 18 or newer was found to run the proxy: the pane offers to install it. */
82  isNodeMissing?: boolean
83}
84
85/** One rule as the rules view shows it: the file's entry, checked. */
86export type ProxyRuleEntry = {
87  id: string
88  name: string | null
89  description: string | null
90  enabled: boolean
91  /** What it does, in words (shared/rules.mjs describeRule). */
92  summary: string
93  errors: string[]
94  /** It holds a script whose code is not approved yet. */
95  isUntrusted: boolean
96}
97
98export type ProxyRules = {
99  file: string | null
100  entries: ProxyRuleEntry[]
101  /** Problems of the file itself (not JSON, wrong shape). */
102  fileErrors: string[]
103}
104
105/** The session's tracked domains: while on and not empty, only these are decrypted and recorded. */
106export type ProxyTracking = {
107  enabled: boolean
108  patterns: string[]
109}
110
111export type ProxySimulator = { udid: string; name: string; runtime: string; state: string }
112
113export type ProxyAndroidDevice = { serial: string; avd: string | null; isEmulator: boolean }
114
115/** What the setup tabs found on this Mac, when they last looked. */
116export type ProxyDevices = {
117  simulators: ProxySimulator[]
118  simulatorError: string | null
119  avds: string[]
120  android: ProxyAndroidDevice[]
121  androidError: string | null
122}
123
124/** The macOS system proxy: whether it points here (scutil), on which service, and whether we set it. */
125export type ProxySystemProxy = { isOn: boolean; service: string | null; isOurs: boolean }
126
127export type ProxySetupTab = 'browser' | 'ios' | 'android' | 'cli'
128
129export type ProxySession = { session: string; project: string; since: number }
130
131/** What the doctor found, and the proxy process as it answered. */
132export type ProxyHealth = {
133  checkedAt: number | null
134  findings: { level: 'ok' | 'info' | 'warn' | 'fail'; title: string; detail?: string; fix?: string; label?: string; action?: unknown }[]
135  process: {
136    pid: number
137    version: string
138    uptimeMs: number
139    rss: number
140    flows: number
141    diskBytes: number
142    sessions: ProxySession[]
143    websockets: number
144    pinned: number
145    isShared: boolean
146  } | null
147}
148
149export type ProxyView = {
150  mode: 'list' | 'detail' | 'setup' | 'rules' | 'domains' | 'health' | 'held'
151  selectedId: number | null
152  setupTab: ProxySetupTab
153  layout?: 'list' | 'tree'
154  /** On the iOS and Android tabs: the simulator/emulator, or a real phone. */
155  device?: 'virtual' | 'real'
156  /** In the rules view: the rule whose ✕ was pressed, waiting for Remove or Keep. */
157  removing?: string | null
158  /** In a request's detail: the tab shown (its own default when unset), the text found in it, and which match is current. */
159  detailTab?: 'request' | 'response' | 'messages' | 'events'
160  detailSearch?: string
161  detailMatch?: number
162}
163
164declare module 'claude-code' {
165  interface PluginState {
166    wirepane: {
167      flows: ProxyFlow[]
168      status: ProxyStatus
169      view: ProxyView
170      filter: string
171      wanted: boolean
172      nextId: number
173      notice: string
174      emulators: string[]
175      expanded: string[]
176      rules: ProxyRules
177      tracking: ProxyTracking
178      /** Hosts that passed through untracked, and how often, since the proxy started. */
179      skipped: Record<string, number>
180      devices: ProxyDevices
181      systemProxy: ProxySystemProxy
182      /** Simulators this session put the CA into. */
183      caSimulators: string[]
184      /** A long action under way (booting a simulator), shown until it ends. */
185      busy: string
186      /** Whether this Mac's keychain trusts the proxy CA, as last checked. */
187      macTrust: 'unknown' | 'trusted' | 'untrusted'
188      /** This mod's version, from its plugin.json. */
189      version: string
190      /** The Claude Code sessions attached to the one shared proxy, this one among them. */
191      sessions: ProxySession[]
192      /** Hosts passed through untouched after refusing the certificate, per client. */
193      pinned: { client: string | null; host: string }[]
194      /** The doctor's last findings, for the Health view. */
195      health: ProxyHealth
196      /** Exchanges held at a breakpoint now. */
197      held: ProxyHeld[]
198    }
199  }
200}
201