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…

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
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
It reads every protocol a modern app speaks.
gRPC NOT_FOUND: no such user.application/x-protobuf), decoded the same way.It reaches every client in one press.
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:
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:
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:
It runs locally.
~/.claude/proxy-mod.| Tool | What 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_rules | The 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./proxy doctor, the Health view (h), or Claude's diagnose check:
| Check | Finds | Fix |
|---|---|---|
| The proxy and its process | Stopped, failed, a port taken (and by whom; a Wirepane 0.7 proxy still running after an update); pid, uptime, memory, disk, sessions | Start again, Stop it and start, Restart |
| The system proxy | Left on by a dead proxy (no internet); held by Charles or Proxyman | Put back; Setup |
| VPN and other proxy apps | A utun default route; Charles, Proxyman, mitmproxy, HTTP Toolkit running | What to try |
| The CA on this Mac | Safari and Mac apps would refuse HTTPS | Trust on this Mac |
| Refusals per client | Every host refused: the CA is missing. One host among working ones: it pins its certificate | Setup for that client, or Never decrypt it |
| Pinned hosts | Passed through after two refusals (or an OkHttp-style close right after the handshake) | Decrypt them again once trusted |
| Upstream failures | DNS (ENOTFOUND), self-signed dev servers, closed ports, localhost confusion, unreachable networks | Accept its certificate; what to check |
| The network's own proxy | The system proxy pointed at an office's or a VPN's proxy before Wirepane took its place | Use it upstream |
| Tracked domains | A list that matches nothing that came, and what passed instead | Add the real hosts |
| Android devices | Not pointed at the proxy; apps that will not trust a user CA; a rootable image | Setup; Trust in all apps |
| Client | How |
|---|---|
| A separate browser | Setup → 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 Simulator | Setup → 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 / iPad | Setup → 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 emulator | Setup → 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 phone | Setup → 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 apps | Setup → 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, Docker | Setup → 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 does not trust the proxy's CA, and with the system proxy on, its own traffic would come here. Two guards keep it whole:
Rules change matching requests before they are sent, responses before the client gets them, and WebSocket messages both ways.
r) lists them in words, with hit counts. It turns them on and off, reorders them, and removes them..claude/proxy-rules.json, so you can commit them. The file is reloaded the moment it changes."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;status (404, 4xx, >=400, 500-599) and contentType.| Action | Request | Response | ||
|---|---|---|---|---|
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>.
Terms separated by spaces must all hold; a leading - negates one. Free text matches a substring of the URL.
| Term | Matches | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
method:POST, method:get,post | the method | |||||||||||||
status:404, status:4xx, status:>=400, status:500-599 | the response status | |||||||||||||
host:api.example.com, host:*.example.com | the host | |||||||||||||
path:/v1/login | a 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-feed | the client, a rule |
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.
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.
The first session that needs the proxy starts it detached; the others attach to it.
/clear and --resume keep everything in place./proxy stop stops it for everyone and says how many other sessions it served./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
Set them in /config, or in /plugin → Installed → wirepane → Configure options.
| Option | Default | |
|---|---|---|
| Proxy port | 8899 | |
| Proxy reachable from | local | lan opens it to phones on the network (never to this Mac's localhost) |
| Hosts never decrypted | *.apple.com,*.icloud.com,*.mzstatic.com,*.apple-cloudkit.com | tunnelled 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 keep | 2000 |
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.
docs/design.md has the details.
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:
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.node sidecar/attach.mjs, one per session, which passes the proxy's events to the mod.node sidecar/watchdog.mjs, which puts the system proxy back if the proxy is killed.openssl: makes the CA and the host certificates.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 pane | Runs | Changes |
|---|---|---|
| macOS → Turn on for this Mac, iOS → Use | networksetup, through osascript when macOS asks for an administrator | The system proxy, put back when the proxy stops |
| macOS → Trust CA on this Mac | security add-trusted-cert, which macOS asks you to confirm | The CA in your login keychain |
| iOS → Use, Boot & use | xcrun simctl, open -a Simulator | The CA in that simulator's keychain |
| Browser → Open | open -na <browser> with a profile of its own | Nothing outside ~/.claude/proxy-mod/browser |
| Android → Start through the proxy, Android → proxy, Point USB phones
hooks/register.tsx 3740 lines1import { 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 lines1// 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}
531shared/systemproxy.mjs 77 lines1// 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}
77hooks/flows.ts 613 lines1// 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}
613hooks/android.ts 42 lines1// 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}
42hooks/diff.ts 104 lines1// 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}
104hooks/doctor.ts 378 lines1// 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}
378hooks/har.ts 79 lines1// 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}
79hooks/model.ts 71 lines1// 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}
71hooks/qr.ts 332 lines1// 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}
332hooks/setup.ts 304 lines1// 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}
304types/index.d.ts 201 lines1export 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