feat(server,web,desktop): admin "use system HTTP proxy" switch (#225)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-08-06 20:21:26 +08:00
1 parent e8499befd4
commit 91f3292ab5
29 files changed
+710 -19

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
/**
* OS-proxy resolution for the embedded server (design § "出网与系统代理"): on the
* desktop, "system proxy" means the real operating-system setting. The shell resolves
* it through Electron's resolveProxy when forking the server and injects the result
* into the child environment as HTTP_PROXY / HTTPS_PROXY — the server's dispatcher
* (and, while the admin switch is on, agent commands) then honor it. Variables already
* present in the shell's environment are never overridden (same idiom as
* bundledShellEnv): an explicitly configured environment wins over the OS lookup.
*
* Only the entry point touches Electron, behind a dynamic import; the parsing and the
* override check stay pure so they unit-test without an Electron runtime.
*/
/** The variables injected, with the probe URL whose scheme selects the OS proxy rule. */
const PROXY_VARS = [
{ name: "HTTP_PROXY", lower: "http_proxy", probe: "http://example.com/" },
{ name: "HTTPS_PROXY", lower: "https_proxy", probe: "https://example.com/" },
] as const;
type ProxyVar = (typeof PROXY_VARS)[number];
/** The proxy variables `env` does not already define (either spelling — those are kept as-is). */
export function unsetProxyVars(env: NodeJS.ProcessEnv): readonly ProxyVar[] {
return PROXY_VARS.filter((v) => env[v.name] === undefined && env[v.lower] === undefined);
}
/**
* Maps one PAC-style resolveProxy result to a proxy URL, or null for "connect
* directly". The result is a `;`-separated fallback list ("PROXY host:port; DIRECT");
* the first usable entry wins: `PROXY` → http://, `HTTPS` (a proxy reached over TLS) →
* https://. `DIRECT` and the SOCKS flavors yield nothing — undici's dispatcher speaks
* only HTTP(S) proxies, and injecting a socks:// URL would break every server fetch.
*/
export function proxyUrlFromPacResult(result: string): string | null {
for (const entry of result.split(";")) {
const m = /^\s*(PROXY|HTTPS)\s+(\S+)\s*$/i.exec(entry);
if (!m) continue;
const scheme = m[1]!.toUpperCase() === "HTTPS" ? "https" : "http";
return `${scheme}://${m[2]!}`;
}
return null;
}
/**
* The proxy environment to add to the server child: the OS resolution of every proxy
* variable the shell's own environment leaves unset. Best-effort — any resolution
* failure injects nothing (the server then simply sees no proxy configuration).
*/
export async function osProxyEnv(): Promise<Record<string, string>> {
const wanted = unsetProxyVars(process.env);
if (wanted.length === 0) return {};
try {
const { session } = await import("electron");
const env: Record<string, string> = {};
for (const v of wanted) {
const url = proxyUrlFromPacResult(await session.defaultSession.resolveProxy(v.probe));
if (url !== null) env[v.name] = url;
}
return env;
} catch {
return {};
}
}
+5
View File
@@ -11,6 +11,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, utilityProcess } from "electron";
import type { UtilityProcess } from "electron";
import { osProxyEnv } from "./os-proxy.js";
import { choosePort, readPreferredPort, rememberPreferredPort } from "./port-memory.js";
import { appOriginFor, parsePortFile } from "./util.js";
@@ -125,6 +126,10 @@ export async function startEmbeddedServer(opts: {
env: {
...process.env,
...bundledShellEnv(),
// OS proxy settings resolved at fork time (Electron resolveProxy) — only for the
// proxy variables the environment leaves unset, never overriding existing values.
// The server's "use system HTTP proxy" switch then governs whether they are used.
...(await osProxyEnv()),
PENGUIN_HOME: opts.dataRoot,
HOST: "127.0.0.1",
PORT: String(requestedPort),
+46
View File
@@ -0,0 +1,46 @@
/**
* Pure-part tests for the OS-proxy injection (no Electron runtime): PAC-result parsing
* and the "never override an existing variable" selection.
*/
import { describe, expect, it } from "vitest";
import { proxyUrlFromPacResult, unsetProxyVars } from "../src/os-proxy.js";
describe("proxyUrlFromPacResult", () => {
it("maps PROXY to an http:// URL and HTTPS to an https:// URL", () => {
expect(proxyUrlFromPacResult("PROXY 127.0.0.1:8888")).toBe("http://127.0.0.1:8888");
expect(proxyUrlFromPacResult("HTTPS proxy.corp.example:443")).toBe(
"https://proxy.corp.example:443",
);
expect(proxyUrlFromPacResult("proxy proxy.corp.example:3128")).toBe(
"http://proxy.corp.example:3128",
);
});
it("takes the first usable entry of a fallback list", () => {
expect(proxyUrlFromPacResult("PROXY 10.0.0.1:8080; DIRECT")).toBe("http://10.0.0.1:8080");
expect(proxyUrlFromPacResult("DIRECT; PROXY 10.0.0.1:8080")).toBe("http://10.0.0.1:8080");
});
it("yields nothing for DIRECT and for SOCKS entries (undici cannot speak SOCKS)", () => {
expect(proxyUrlFromPacResult("DIRECT")).toBeNull();
expect(proxyUrlFromPacResult("SOCKS5 127.0.0.1:1080")).toBeNull();
expect(proxyUrlFromPacResult("SOCKS 127.0.0.1:1080; DIRECT")).toBeNull();
expect(proxyUrlFromPacResult("")).toBeNull();
});
});
describe("unsetProxyVars", () => {
it("selects both variables on a clean environment", () => {
expect(unsetProxyVars({}).map((v) => v.name)).toEqual(["HTTP_PROXY", "HTTPS_PROXY"]);
});
it("skips a variable already present in either spelling (never overridden)", () => {
expect(unsetProxyVars({ HTTP_PROXY: "http://a:1" }).map((v) => v.name)).toEqual([
"HTTPS_PROXY",
]);
expect(unsetProxyVars({ https_proxy: "http://a:1" }).map((v) => v.name)).toEqual([
"HTTP_PROXY",
]);
expect(unsetProxyVars({ HTTP_PROXY: "http://a:1", https_proxy: "http://a:1" })).toEqual([]);
});
});