ADR 0177: User-configurable outbound proxy
- Status: Accepted
- Date: 2026-09-08
- Deciders: PI-Desktop core
- Related: D340, ADR 0083, ADR 0096,
04-ux/06-settings-ia.md,03-runtime/07-process-model.md
Context
Outbound HTTP is split across processes:
- Electron main — model discovery, models.dev refresh, plugin
net.fetch, OAuth polling, GitHub-adjacent fetches - Agent sidecar (Node
fetch/ pi-ai) — LLM provider calls - host-core — marketplace catalog and package
curl - Chromium sessions — default session,
persist:work-browser, plugin panels - electron-updater — Chromium / Electron net
None of those surfaces honored a product setting. Users behind Clash, V2Ray, corporate HTTP proxies, or SOCKS5 had working OS/TUN proxies for the in-app browser but silent failures on model calls, because Node's fetch does not use the system proxy.
A single Settings control should apply one proxy to app-owned traffic.
Decision
Settings → General → Network exposes Proxy as System / Direct / Custom. Custom accepts
http,https,socks,socks5, andsocks5hURLs plus a bypass list. Persistence is optionalAppSettings.networkProxyin the existing host settings blob. No protocol or storage schema version bump.System (default): Chromium
session.setProxy({ mode: "system" }). Node sidecar stays direct unless the process already inherited proxy env from the launching shell. This preserves today's GUI-app behavior.Direct: Chromium
{ mode: "direct" }. Proxy env keys are cleared in Electron main. host-core marketplace curl uses--noproxy '*'.Custom: Chromium
proxyRules+proxyBypassRules; Electron mainfetchisnet.fetchso SOCKS5 uses the Chromium stack; sidecar sets an undici dispatcher (ProxyAgentfor HTTP(S), SOCKS5 CONNECT + the same undicifetch); host-core curl gets--proxy/--noproxyfrom the stored settings. Default bypass islocalhost,127.0.0.1,::1,<local>so loopback MCP and local models stay direct.Not rewritten: workspace Bash (host-core spawn env strips proxy keys so credentials cannot leak into
env), and the system browser used for OAuth (shell.openExternal). Plugin utility processes keep their stripped env;pi.net.fetchstill goes through main.Apply without restart.
settings.setupdates Chromium sessions (includingsession-created), main env, andsidecar.configure. A Test action (pi-desktop/network/testProxy) runs one bounded Chromium fetch through the supplied config and does not persist it.Secrets. Proxy userinfo lives in the settings JSON next to other non-API-key preferences. Logs redact passwords. host-core never
set_vars the URL onto its process env. ChromiumproxyRulescannot include userinfo (it fails withnet::ERR_NO_SUPPORTED_PROXIES) and cannot speak SOCKS5 username/password, so Electron main points Chromium at a127.0.0.1SOCKS5 relay that injects the stored credentials (issue #490). Node, undici, and curl keep the canonical URL with userinfo.
Consequences
- LLM, marketplace, updates, and the in-app browser share one proxy.
- System mode does not magically make Node follow the macOS/Windows system proxy; users who need model calls through Clash still choose Custom (typically
http://127.0.0.1:7890orsocks5://127.0.0.1:1080). - Adding
undicito the sidecar bundle keeps the dispatcher andfetchimplementation on one package.
Alternatives
- Env-only (
HTTP_PROXY): Nodefetchignores it without a dispatcher; SOCKS5 is incomplete; host-core Bash would inherit credentials. app.commandLine.appendSwitch('proxy-server'): cannot change at runtime.- Per-provider proxy: does not cover marketplace, updates, or the browser.