From 412c128d6c458eb11ab1f35a76a967cabb4f68c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:40:11 +0000 Subject: [PATCH 1/7] test(server): reproduce #2709 macOS system proxy missing from provider updates This test reproduces #2709: on darwin, with no proxy variable in the inherited environment, the provider updater is spawned without the HTTP(S) proxy configured in macOS System Settings (scutil --proxy). Co-Authored-By: Claude Opus 5.5 --- .../providerMaintenanceRunner.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 75c592b7811a1..2a2543a6586a5 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -672,6 +672,54 @@ describe("providerMaintenanceRunner", () => { ), ); }); + + it.effect("passes the macOS system proxy to the updater when the env has none", () => { + const updaterEnvs: Array = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner(registry); + + yield* runner.updateProvider(CODEX_DRIVER); + + assert.strictEqual(updaterEnvs.length, 1); + assert.strictEqual(updaterEnvs[0]?.HTTPS_PROXY, "http://127.0.0.1:7890"); + assert.strictEqual(updaterEnvs[0]?.HTTP_PROXY, "http://127.0.0.1:7890"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Platform, "darwin"), + Layer.succeed(HostProcess.Environment, { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }), + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner((command, _args, options) => { + if (command.split("/").pop() === "scutil") { + return { + stdout: [ + " {", + " ExceptionsList : {", + " 0 : *.local", + " 1 : 169.254/16", + " }", + " FTPPassive : 1", + " HTTPEnable : 1", + " HTTPPort : 7890", + " HTTPProxy : 127.0.0.1", + " HTTPSEnable : 1", + " HTTPSPort : 7890", + " HTTPSProxy : 127.0.0.1", + "}", + "", + ].join("\n"), + }; + } + if (command === "npm") { + updaterEnvs.push(options.env); + } + return { stdout: "updated" }; + }), + ), + ), + ); + }); }); it.effect.each([ From 5244014d545a2775614b49b0cafabb9bcf847b3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 02:52:49 +0000 Subject: [PATCH 2/7] fix(server): provider updates on macOS use the system proxy A Dock-launched desktop app has no HTTP(S)_PROXY in its env, so `npm install -g`, `brew upgrade` and `opencode upgrade` ran unproxied and timed out on networks that need the proxy set in System Settings. On darwin, when the inherited env has no HTTP_PROXY, HTTPS_PROXY or ALL_PROXY (either case), each update now reads `scutil --proxy` (2 s timeout, any failure means no proxy env) and passes the enabled HTTP and HTTPS proxies as HTTP(S)_PROXY/http(s)_proxy, with NO_PROXY from the exceptions list plus loopback. The capability's own env still wins. Linux and Windows spawn nothing new. Fixes pingdotgg/t3code#2709 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/macosSystemProxy.test.ts | 60 ++++++++++++ apps/server/src/provider/macosSystemProxy.ts | 91 +++++++++++++++++++ .../providerMaintenanceRunner.test.ts | 24 +++++ .../src/provider/providerMaintenanceRunner.ts | 28 +++--- 4 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/macosSystemProxy.test.ts create mode 100644 apps/server/src/provider/macosSystemProxy.ts diff --git a/apps/server/src/provider/macosSystemProxy.test.ts b/apps/server/src/provider/macosSystemProxy.test.ts new file mode 100644 index 0000000000000..c4b97f81af4f9 --- /dev/null +++ b/apps/server/src/provider/macosSystemProxy.test.ts @@ -0,0 +1,60 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { parseScutilProxy } from "./macosSystemProxy.ts"; + +const dictionary = (...lines: ReadonlyArray) => + [" {", ...lines.map((line) => ` ${line}`), "}", ""].join("\n"); + +describe("parseScutilProxy", () => { + it("ignores disabled, incomplete, SOCKS and PAC proxies", () => { + assert.deepStrictEqual( + parseScutilProxy( + dictionary( + "HTTPEnable : 0", + "HTTPProxy : 10.0.0.1", + "HTTPPort : 3128", + "HTTPSEnable : 1", + "HTTPSProxy : 10.0.0.1", + "ProxyAutoConfigEnable : 1", + "ProxyAutoConfigURLString : http://wpad/proxy.pac", + "SOCKSEnable : 1", + "SOCKSPort : 1080", + "SOCKSProxy : 10.0.0.1", + ), + ), + {}, + ); + assert.deepStrictEqual(parseScutilProxy(""), {}); + }); + + it("reads only the top-level settings and converts exceptions to NO_PROXY", () => { + assert.deepStrictEqual( + parseScutilProxy( + dictionary( + "ExceptionsList : {", + " 0 : *.local", + " 1 : 169.254/16", + " 2 : 10.0.0.0/8", + " 3 : internal.corp", + "}", + "HTTPSEnable : 1", + "HTTPSPort : 8443", + "HTTPSProxy : fd00::1", + "__SCOPED__ : {", + " en0 : {", + " HTTPEnable : 1", + " HTTPPort : 9999", + " HTTPProxy : 10.9.9.9", + " }", + "}", + ), + ), + { + HTTPS_PROXY: "http://[fd00::1]:8443", + https_proxy: "http://[fd00::1]:8443", + NO_PROXY: "localhost,127.0.0.1,::1,.local,169.254.0.0/16,10.0.0.0/8,internal.corp", + no_proxy: "localhost,127.0.0.1,::1,.local,169.254.0.0/16,10.0.0.0/8,internal.corp", + }, + ); + }); +}); diff --git a/apps/server/src/provider/macosSystemProxy.ts b/apps/server/src/provider/macosSystemProxy.ts new file mode 100644 index 0000000000000..42e06cdc42078 --- /dev/null +++ b/apps/server/src/provider/macosSystemProxy.ts @@ -0,0 +1,91 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]; +// CFNetwork never sends loopback through the system proxy; env-proxy clients do unless told. +const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"]; + +function proxyUrl(fields: ReadonlyMap, prefix: "HTTP" | "HTTPS"): string | null { + const host = fields.get(`${prefix}Proxy`); + const port = fields.get(`${prefix}Port`); + if (fields.get(`${prefix}Enable`) !== "1" || !host || !port || !/^\d+$/.test(port)) { + return null; + } + return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`; +} + +// macOS writes `*.local` and `169.254/16`; curl and npm match `.local` and full CIDRs. +function toNoProxyEntry(exception: string): string { + if (exception.startsWith("*.")) { + return exception.slice(1); + } + const cidr = /^(\d+(?:\.\d+){0,3})\/(\d+)$/.exec(exception); + if (!cidr) { + return exception; + } + const octets = cidr[1]!.split("."); + while (octets.length < 4) octets.push("0"); + return `${octets.join(".")}/${cidr[2]}`; +} + +/** Maps `scutil --proxy` output to proxy env vars; SOCKS and PAC are ignored. */ +export function parseScutilProxy(output: string): Record { + const fields = new Map(); + const exceptions: Array = []; + let depth = 0; + let inExceptions = false; + for (const line of output.split("\n")) { + const text = line.trim(); + if (text === "}") { + depth -= 1; + if (depth <= 1) inExceptions = false; + continue; + } + const separator = text.indexOf(" : "); + const key = separator === -1 ? "" : text.slice(0, separator); + const value = separator === -1 ? "" : text.slice(separator + 3).trim(); + if (text.endsWith("{")) { + inExceptions = depth === 1 && key === "ExceptionsList"; + depth += 1; + } else if (depth === 1 && key) { + fields.set(key, value); + } else if (inExceptions && depth === 2 && value) { + exceptions.push(value); + } + } + + const http = proxyUrl(fields, "HTTP"); + const https = proxyUrl(fields, "HTTPS"); + if (!http && !https) { + return {}; + } + const noProxy = [...LOOPBACK_NO_PROXY, ...exceptions.map(toNoProxyEntry)].join(","); + return { + ...(http ? { HTTP_PROXY: http, http_proxy: http } : {}), + ...(https ? { HTTPS_PROXY: https, https_proxy: https } : {}), + NO_PROXY: noProxy, + no_proxy: noProxy, + }; +} + +function hasProxyEnv(env: NodeJS.ProcessEnv): boolean { + return PROXY_ENV_KEYS.some((key) => Boolean(env[key] || env[key.toLowerCase()])); +} + +export const readMacosSystemProxyEnv = Effect.fn("MacosSystemProxy.readEnv")(function* ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +) { + if ((yield* HostProcess.Platform) !== "darwin" || hasProxyEnv(yield* HostProcess.Environment)) { + return {}; + } + const output = yield* spawner + .string( + ChildProcess.make("/usr/sbin/scutil", ["--proxy"], { stdin: "ignore", stderr: "ignore" }), + ) + .pipe( + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => ""), + ); + return parseScutilProxy(output); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 2a2543a6586a5..1ee4032660385 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -673,6 +673,30 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("leaves an inherited proxy alone and does not read the macOS system proxy", () => { + const spawns: Array<{ command: string; env: NodeJS.ProcessEnv | undefined }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner(registry); + + yield* runner.updateProvider(CODEX_DRIVER); + + assert.deepStrictEqual(spawns, [{ command: "npm", env: undefined }]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Platform, "darwin"), + Layer.succeed(HostProcess.Environment, { https_proxy: "http://proxy.corp:8080" }), + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner((command, _args, options) => { + spawns.push({ command, env: options.env }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + it.effect("passes the macOS system proxy to the updater when the env has none", () => { const updaterEnvs: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 9dddf23579d8a..111f2b7319e93 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -24,6 +24,7 @@ import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as NodeUtil from "node:util"; +import { readMacosSystemProxyEnv } from "./macosSystemProxy.ts"; import * as ModelManifest from "./ModelManifest.ts"; import { resolveProviderCompatibility } from "./providerCompatibility.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; @@ -296,17 +297,22 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const runMaintenanceCommand = ( - update: ProviderMaintenanceCommandAction, - onProgress: (line: string) => Effect.Effect, - ) => - runProviderMaintenanceCommandWithSpawner({ - spawner, - command: update.executable, - args: update.args, - onProgress, - ...(update.env ? { env: update.env } : {}), - }); + const runMaintenanceCommand = Effect.fn("ProviderMaintenanceRunner.runMaintenanceCommand")( + function* ( + update: ProviderMaintenanceCommandAction, + onProgress: (line: string) => Effect.Effect, + ) { + // Read per update: the user may toggle the system proxy while T3 Code runs. + const env = { ...(yield* readMacosSystemProxyEnv(spawner)), ...update.env }; + return yield* runProviderMaintenanceCommandWithSpawner({ + spawner, + command: update.executable, + args: update.args, + onProgress, + ...(Object.keys(env).length > 0 ? { env } : {}), + }); + }, + ); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ makeAlreadyRunningError: () => new ServerProviderUpdateError({ From dd6301c79a0c958b7e105d864fde2faa86ad39b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 03:06:57 +0000 Subject: [PATCH 3/7] fix(server): keep updater proxy and NO_PROXY settings when adding the macOS system proxy A capability that declares its own proxy now skips the system proxy entirely, so a generated lowercase https_proxy cannot shadow it for curl. An inherited NO_PROXY is merged into the generated list instead of replaced, and npm's bracketed IPv6 loopback is bypassed too. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/macosSystemProxy.test.ts | 9 +++-- apps/server/src/provider/macosSystemProxy.ts | 22 ++++++++--- .../providerMaintenanceRunner.test.ts | 39 +++++++++++++++++++ .../src/provider/providerMaintenanceRunner.ts | 2 +- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/macosSystemProxy.test.ts b/apps/server/src/provider/macosSystemProxy.test.ts index c4b97f81af4f9..99fe7b7bf1759 100644 --- a/apps/server/src/provider/macosSystemProxy.test.ts +++ b/apps/server/src/provider/macosSystemProxy.test.ts @@ -27,7 +27,7 @@ describe("parseScutilProxy", () => { assert.deepStrictEqual(parseScutilProxy(""), {}); }); - it("reads only the top-level settings and converts exceptions to NO_PROXY", () => { + it("reads only the top-level settings and merges exceptions into NO_PROXY", () => { assert.deepStrictEqual( parseScutilProxy( dictionary( @@ -48,12 +48,15 @@ describe("parseScutilProxy", () => { " }", "}", ), + "registry.corp, ", ), { HTTPS_PROXY: "http://[fd00::1]:8443", https_proxy: "http://[fd00::1]:8443", - NO_PROXY: "localhost,127.0.0.1,::1,.local,169.254.0.0/16,10.0.0.0/8,internal.corp", - no_proxy: "localhost,127.0.0.1,::1,.local,169.254.0.0/16,10.0.0.0/8,internal.corp", + NO_PROXY: + "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,10.0.0.0/8,internal.corp", + no_proxy: + "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,10.0.0.0/8,internal.corp", }, ); }); diff --git a/apps/server/src/provider/macosSystemProxy.ts b/apps/server/src/provider/macosSystemProxy.ts index 42e06cdc42078..ee045733c9ccf 100644 --- a/apps/server/src/provider/macosSystemProxy.ts +++ b/apps/server/src/provider/macosSystemProxy.ts @@ -4,7 +4,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]; // CFNetwork never sends loopback through the system proxy; env-proxy clients do unless told. -const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"]; +const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1", "[::1]"]; function proxyUrl(fields: ReadonlyMap, prefix: "HTTP" | "HTTPS"): string | null { const host = fields.get(`${prefix}Proxy`); @@ -15,7 +15,7 @@ function proxyUrl(fields: ReadonlyMap, prefix: "HTTP" | "HTTPS") return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`; } -// macOS writes `*.local` and `169.254/16`; curl and npm match `.local` and full CIDRs. +// macOS writes `*.local` and `169.254/16`; curl matches `.local` and full CIDRs (npm ignores CIDRs). function toNoProxyEntry(exception: string): string { if (exception.startsWith("*.")) { return exception.slice(1); @@ -30,7 +30,7 @@ function toNoProxyEntry(exception: string): string { } /** Maps `scutil --proxy` output to proxy env vars; SOCKS and PAC are ignored. */ -export function parseScutilProxy(output: string): Record { +export function parseScutilProxy(output: string, inheritedNoProxy = ""): Record { const fields = new Map(); const exceptions: Array = []; let depth = 0; @@ -60,7 +60,11 @@ export function parseScutilProxy(output: string): Record { if (!http && !https) { return {}; } - const noProxy = [...LOOPBACK_NO_PROXY, ...exceptions.map(toNoProxyEntry)].join(","); + const noProxy = [ + ...inheritedNoProxy.split(",").flatMap((entry) => entry.trim() || []), + ...LOOPBACK_NO_PROXY, + ...exceptions.map(toNoProxyEntry), + ].join(","); return { ...(http ? { HTTP_PROXY: http, http_proxy: http } : {}), ...(https ? { HTTPS_PROXY: https, https_proxy: https } : {}), @@ -75,8 +79,14 @@ function hasProxyEnv(env: NodeJS.ProcessEnv): boolean { export const readMacosSystemProxyEnv = Effect.fn("MacosSystemProxy.readEnv")(function* ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + commandEnv: NodeJS.ProcessEnv = {}, ) { - if ((yield* HostProcess.Platform) !== "darwin" || hasProxyEnv(yield* HostProcess.Environment)) { + const inherited = yield* HostProcess.Environment; + if ( + (yield* HostProcess.Platform) !== "darwin" || + hasProxyEnv(inherited) || + hasProxyEnv(commandEnv) + ) { return {}; } const output = yield* spawner @@ -87,5 +97,5 @@ export const readMacosSystemProxyEnv = Effect.fn("MacosSystemProxy.readEnv")(fun Effect.timeout("2 seconds"), Effect.orElseSucceed(() => ""), ); - return parseScutilProxy(output); + return parseScutilProxy(output, inherited.NO_PROXY || inherited.no_proxy); }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 1ee4032660385..1fad6b8470dc4 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -697,6 +697,45 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("does not read the macOS system proxy when the updater declares its own proxy", () => { + const spawns: Array<{ command: string; env: NodeJS.ProcessEnv | undefined }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed({ + ...lifecycleFor(provider), + update: { + command: "npm install -g @openai/codex@latest", + executable: "npm", + args: ["install", "-g", "@openai/codex@latest"], + lockKey: "npm-global", + env: { HTTPS_PROXY: "http://instance.proxy:3128" }, + }, + }), + }); + + yield* runner.updateProvider(CODEX_DRIVER); + + assert.deepStrictEqual(spawns, [ + { command: "npm", env: { HTTPS_PROXY: "http://instance.proxy:3128" } }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Platform, "darwin"), + Layer.succeed(HostProcess.Environment, { PATH: "/usr/bin:/bin" }), + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner((command, _args, options) => { + spawns.push({ command, env: options.env }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + it.effect("passes the macOS system proxy to the updater when the env has none", () => { const updaterEnvs: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 111f2b7319e93..c5c0c53e706ea 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -303,7 +303,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { onProgress: (line: string) => Effect.Effect, ) { // Read per update: the user may toggle the system proxy while T3 Code runs. - const env = { ...(yield* readMacosSystemProxyEnv(spawner)), ...update.env }; + const env = { ...(yield* readMacosSystemProxyEnv(spawner, update.env)), ...update.env }; return yield* runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, From f7e512c4398cc370f643b83325c8da6270a0c5ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 03:20:37 +0000 Subject: [PATCH 4/7] fix(server): honour the updater's NO_PROXY and a bypass-all no_proxy Providers that pass their whole environment as update.env (Grok, Muse) no longer overwrite the merged bypass list, the lowercase no_proxy wins as it does for curl, and NO_PROXY=* skips the system proxy entirely. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/macosSystemProxy.ts | 8 ++- .../providerMaintenanceRunner.test.ts | 64 +++++++++++++++++++ .../src/provider/providerMaintenanceRunner.ts | 2 +- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/macosSystemProxy.ts b/apps/server/src/provider/macosSystemProxy.ts index ee045733c9ccf..16f050e54563f 100644 --- a/apps/server/src/provider/macosSystemProxy.ts +++ b/apps/server/src/provider/macosSystemProxy.ts @@ -82,10 +82,14 @@ export const readMacosSystemProxyEnv = Effect.fn("MacosSystemProxy.readEnv")(fun commandEnv: NodeJS.ProcessEnv = {}, ) { const inherited = yield* HostProcess.Environment; + // curl reads the lowercase form first. + const noProxy = + commandEnv.no_proxy || commandEnv.NO_PROXY || inherited.no_proxy || inherited.NO_PROXY || ""; if ( (yield* HostProcess.Platform) !== "darwin" || hasProxyEnv(inherited) || - hasProxyEnv(commandEnv) + hasProxyEnv(commandEnv) || + noProxy.trim() === "*" ) { return {}; } @@ -97,5 +101,5 @@ export const readMacosSystemProxyEnv = Effect.fn("MacosSystemProxy.readEnv")(fun Effect.timeout("2 seconds"), Effect.orElseSucceed(() => ""), ); - return parseScutilProxy(output, inherited.NO_PROXY || inherited.no_proxy); + return parseScutilProxy(output, noProxy); }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 1fad6b8470dc4..6ab57d1f969ed 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -736,6 +736,70 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect.each([ + { + name: "merges an updater's NO_PROXY into the system proxy bypass list", + updateEnv: { NO_PROXY: "registry.corp" }, + expected: ["scutil", "npm"], + }, + { + name: "does not read the macOS system proxy when NO_PROXY bypasses everything", + updateEnv: { no_proxy: "*" }, + expected: ["npm"], + }, + ])("$name", ({ updateEnv, expected }) => { + const spawns: Array<{ command: string; env: NodeJS.ProcessEnv | undefined }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed({ + ...lifecycleFor(provider), + update: { + command: "npm install -g @openai/codex@latest", + executable: "npm", + args: ["install", "-g", "@openai/codex@latest"], + lockKey: "npm-global", + env: updateEnv, + }, + }), + }); + + yield* runner.updateProvider(CODEX_DRIVER); + + assert.deepStrictEqual( + spawns.map(({ command }) => command.split("/").pop()), + expected, + ); + const updaterEnv = spawns.at(-1)?.env; + if (expected.includes("scutil")) { + assert.strictEqual(updaterEnv?.HTTPS_PROXY, "http://127.0.0.1:7890"); + assert.strictEqual(updaterEnv?.NO_PROXY, updaterEnv?.no_proxy); + assert.match(updaterEnv?.NO_PROXY ?? "", /^registry\.corp,localhost,/); + } else { + assert.deepStrictEqual(updaterEnv, updateEnv); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Platform, "darwin"), + Layer.succeed(HostProcess.Environment, { PATH: "/usr/bin:/bin" }), + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner((command, _args, options) => { + spawns.push({ command, env: options.env }); + return command.endsWith("scutil") + ? { + stdout: + " {\n HTTPSEnable : 1\n HTTPSPort : 7890\n HTTPSProxy : 127.0.0.1\n}\n", + } + : { stdout: "updated" }; + }), + ), + ), + ); + }); + it.effect("passes the macOS system proxy to the updater when the env has none", () => { const updaterEnvs: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index c5c0c53e706ea..5a2837574e5b2 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -303,7 +303,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { onProgress: (line: string) => Effect.Effect, ) { // Read per update: the user may toggle the system proxy while T3 Code runs. - const env = { ...(yield* readMacosSystemProxyEnv(spawner, update.env)), ...update.env }; + const env = { ...update.env, ...(yield* readMacosSystemProxyEnv(spawner, update.env)) }; return yield* runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, From 4c9daa4f779784a6d8b092390eacd4798774d4c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:55:54 +0000 Subject: [PATCH 5/7] fix(server): keep direct updates when macOS proxy exceptions hold IP ranges npm matches NO_PROXY by hostname suffix and ignores CIDR and wildcard IP entries, so passing the system proxy would send a bypassed private registry through the proxy. When the exception list holds such an entry (other than the default link-local range), the update runs as before. Also fold the runner's skip-case tests into one table. AI-assisted; verified with the tests and tools listed above. --- .../src/provider/macosSystemProxy.test.ts | 28 ++++-- apps/server/src/provider/macosSystemProxy.ts | 29 +++--- .../providerMaintenanceRunner.test.ts | 97 +++---------------- 3 files changed, 54 insertions(+), 100 deletions(-) diff --git a/apps/server/src/provider/macosSystemProxy.test.ts b/apps/server/src/provider/macosSystemProxy.test.ts index 99fe7b7bf1759..b811ff1f998cb 100644 --- a/apps/server/src/provider/macosSystemProxy.test.ts +++ b/apps/server/src/provider/macosSystemProxy.test.ts @@ -27,6 +27,25 @@ describe("parseScutilProxy", () => { assert.deepStrictEqual(parseScutilProxy(""), {}); }); + it.each(["192.168.0.0/16", "10.*"])( + "keeps the direct connection when the exceptions include %s, which npm cannot match", + (exception) => { + assert.deepStrictEqual( + parseScutilProxy( + dictionary( + "ExceptionsList : {", + ` 0 : ${exception}`, + "}", + "HTTPEnable : 1", + "HTTPPort : 3128", + "HTTPProxy : 10.0.0.1", + ), + ), + {}, + ); + }, + ); + it("reads only the top-level settings and merges exceptions into NO_PROXY", () => { assert.deepStrictEqual( parseScutilProxy( @@ -34,8 +53,7 @@ describe("parseScutilProxy", () => { "ExceptionsList : {", " 0 : *.local", " 1 : 169.254/16", - " 2 : 10.0.0.0/8", - " 3 : internal.corp", + " 2 : internal.corp", "}", "HTTPSEnable : 1", "HTTPSPort : 8443", @@ -53,10 +71,8 @@ describe("parseScutilProxy", () => { { HTTPS_PROXY: "http://[fd00::1]:8443", https_proxy: "http://[fd00::1]:8443", - NO_PROXY: - "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,10.0.0.0/8,internal.corp", - no_proxy: - "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,10.0.0.0/8,internal.corp", + NO_PROXY: "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,internal.corp", + no_proxy: "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,internal.corp", }, ); }); diff --git a/apps/server/src/provider/macosSystemProxy.ts b/apps/server/src/provider/macosSystemProxy.ts index 16f050e54563f..c4c8e4c6a5b20 100644 --- a/apps/server/src/provider/macosSystemProxy.ts +++ b/apps/server/src/provider/macosSystemProxy.ts @@ -15,18 +15,23 @@ function proxyUrl(fields: ReadonlyMap, prefix: "HTTP" | "HTTPS") return `http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`; } -// macOS writes `*.local` and `169.254/16`; curl matches `.local` and full CIDRs (npm ignores CIDRs). +// macOS writes `*.local` and `169.254/16` by default. +const LINK_LOCAL_RANGE = /^169\.254(?:\.0){0,2}\/16$/; + function toNoProxyEntry(exception: string): string { - if (exception.startsWith("*.")) { - return exception.slice(1); - } - const cidr = /^(\d+(?:\.\d+){0,3})\/(\d+)$/.exec(exception); - if (!cidr) { - return exception; - } - const octets = cidr[1]!.split("."); - while (octets.length < 4) octets.push("0"); - return `${octets.join(".")}/${cidr[2]}`; + if (LINK_LOCAL_RANGE.test(exception)) return "169.254.0.0/16"; + return exception.startsWith("*.") ? exception.slice(1) : exception; +} + +/** + * npm matches NO_PROXY by hostname suffix and ignores IP ranges, so a bypassed + * private registry would go through the proxy. Such lists keep main's direct connection. + */ +function hasUnrepresentableException(exception: string): boolean { + if (LINK_LOCAL_RANGE.test(exception)) return false; + return ( + exception.includes("/") || exception.slice(exception.startsWith("*.") ? 2 : 0).includes("*") + ); } /** Maps `scutil --proxy` output to proxy env vars; SOCKS and PAC are ignored. */ @@ -57,7 +62,7 @@ export function parseScutilProxy(output: string, inheritedNoProxy = ""): Record< const http = proxyUrl(fields, "HTTP"); const https = proxyUrl(fields, "HTTPS"); - if (!http && !https) { + if ((!http && !https) || exceptions.some(hasUnrepresentableException)) { return {}; } const noProxy = [ diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f897c146c1319..fd88bb3093b0d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -976,31 +976,11 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("leaves an inherited proxy alone and does not read the macOS system proxy", () => { - const spawns: Array<{ command: string; env: NodeJS.ProcessEnv | undefined }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); - - yield* runner.updateProvider(CODEX_DRIVER); - - assert.deepStrictEqual(spawns, [{ command: "npm", env: undefined }]); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "darwin"), - Layer.succeed(HostProcess.Environment, { https_proxy: "http://proxy.corp:8080" }), - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((command, _args, options) => { - spawns.push({ command, env: options.env }); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - - it.effect("does not read the macOS system proxy when the updater declares its own proxy", () => { + it.effect.each([ + { name: "an inherited proxy", inherited: { https_proxy: "http://proxy.corp:8080" }, env: {} }, + { name: "an updater proxy", inherited: {}, env: { HTTPS_PROXY: "http://instance:3128" } }, + { name: "a bypass-all NO_PROXY", inherited: {}, env: { no_proxy: "*" } }, + ])("does not read the macOS system proxy with $name", ({ inherited, env }) => { const spawns: Array<{ command: string; env: NodeJS.ProcessEnv | undefined }> = []; return Effect.gen(function* () { const { registry } = yield* makeRegistry(baseProvider); @@ -1014,7 +994,7 @@ describe("providerMaintenanceRunner", () => { executable: "npm", args: ["install", "-g", "@openai/codex@latest"], lockKey: "npm-global", - env: { HTTPS_PROXY: "http://instance.proxy:3128" }, + env, }, }), }); @@ -1022,13 +1002,13 @@ describe("providerMaintenanceRunner", () => { yield* runner.updateProvider(CODEX_DRIVER); assert.deepStrictEqual(spawns, [ - { command: "npm", env: { HTTPS_PROXY: "http://instance.proxy:3128" } }, + { command: "npm", env: Object.keys(env).length > 0 ? env : undefined }, ]); }).pipe( Effect.provide( Layer.mergeAll( Layer.succeed(HostProcess.Platform, "darwin"), - Layer.succeed(HostProcess.Environment, { PATH: "/usr/bin:/bin" }), + Layer.succeed(HostProcess.Environment, inherited), layerLatestVersionHttpClient("0.0.0"), layerMockSpawner((command, _args, options) => { spawns.push({ command, env: options.env }); @@ -1039,19 +1019,8 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect.each([ - { - name: "merges an updater's NO_PROXY into the system proxy bypass list", - updateEnv: { NO_PROXY: "registry.corp" }, - expected: ["scutil", "npm"], - }, - { - name: "does not read the macOS system proxy when NO_PROXY bypasses everything", - updateEnv: { no_proxy: "*" }, - expected: ["npm"], - }, - ])("$name", ({ updateEnv, expected }) => { - const spawns: Array<{ command: string; env: NodeJS.ProcessEnv | undefined }> = []; + it.effect("passes the macOS system proxy to the updater when the env has none", () => { + const updaterEnvs: Array = []; return Effect.gen(function* () { const { registry } = yield* makeRegistry(baseProvider); const runner = yield* makeTestRunner({ @@ -1064,56 +1033,20 @@ describe("providerMaintenanceRunner", () => { executable: "npm", args: ["install", "-g", "@openai/codex@latest"], lockKey: "npm-global", - env: updateEnv, + env: { NO_PROXY: "registry.corp" }, }, }), }); yield* runner.updateProvider(CODEX_DRIVER); - assert.deepStrictEqual( - spawns.map(({ command }) => command.split("/").pop()), - expected, - ); - const updaterEnv = spawns.at(-1)?.env; - if (expected.includes("scutil")) { - assert.strictEqual(updaterEnv?.HTTPS_PROXY, "http://127.0.0.1:7890"); - assert.strictEqual(updaterEnv?.NO_PROXY, updaterEnv?.no_proxy); - assert.match(updaterEnv?.NO_PROXY ?? "", /^registry\.corp,localhost,/); - } else { - assert.deepStrictEqual(updaterEnv, updateEnv); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcess.Platform, "darwin"), - Layer.succeed(HostProcess.Environment, { PATH: "/usr/bin:/bin" }), - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((command, _args, options) => { - spawns.push({ command, env: options.env }); - return command.endsWith("scutil") - ? { - stdout: - " {\n HTTPSEnable : 1\n HTTPSPort : 7890\n HTTPSProxy : 127.0.0.1\n}\n", - } - : { stdout: "updated" }; - }), - ), - ), - ); - }); - - it.effect("passes the macOS system proxy to the updater when the env has none", () => { - const updaterEnvs: Array = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); - - yield* runner.updateProvider(CODEX_DRIVER); - assert.strictEqual(updaterEnvs.length, 1); assert.strictEqual(updaterEnvs[0]?.HTTPS_PROXY, "http://127.0.0.1:7890"); assert.strictEqual(updaterEnvs[0]?.HTTP_PROXY, "http://127.0.0.1:7890"); + assert.strictEqual( + updaterEnvs[0]?.no_proxy, + "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16", + ); }).pipe( Effect.provide( Layer.mergeAll( From 62be6b9f669bf2db9bd70c8498646f0a74f2022c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:10:43 +0000 Subject: [PATCH 6/7] fix(server): keep direct updates when NO_PROXY ranges or simple-hostname bypass apply npm cannot match IP ranges or dotless hosts in NO_PROXY. Apply the existing guard to the inherited and updater NO_PROXY entries too, and keep the direct connection when macOS excludes simple hostnames. AI-assisted; verified with the tests and tools listed above. --- .../src/provider/macosSystemProxy.test.ts | 38 ++++++++++--------- apps/server/src/provider/macosSystemProxy.ts | 16 ++++---- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/macosSystemProxy.test.ts b/apps/server/src/provider/macosSystemProxy.test.ts index b811ff1f998cb..83904f7282201 100644 --- a/apps/server/src/provider/macosSystemProxy.test.ts +++ b/apps/server/src/provider/macosSystemProxy.test.ts @@ -27,24 +27,28 @@ describe("parseScutilProxy", () => { assert.deepStrictEqual(parseScutilProxy(""), {}); }); - it.each(["192.168.0.0/16", "10.*"])( - "keeps the direct connection when the exceptions include %s, which npm cannot match", - (exception) => { - assert.deepStrictEqual( - parseScutilProxy( - dictionary( - "ExceptionsList : {", - ` 0 : ${exception}`, - "}", - "HTTPEnable : 1", - "HTTPPort : 3128", - "HTTPProxy : 10.0.0.1", - ), - ), - {}, - ); + it.each([ + { + name: "an IP range exception", + lines: ["ExceptionsList : {", " 0 : 192.168.0.0/16", "}"], + noProxy: "", + }, + { + name: "a wildcard IP exception", + lines: ["ExceptionsList : {", " 0 : 10.*", "}"], + noProxy: "", }, - ); + { name: "an IP range in NO_PROXY", lines: [], noProxy: "registry.corp,127.0.0.0/8" }, + { name: "simple hostnames excluded", lines: ["ExcludeSimpleHostnames : 1"], noProxy: "" }, + ])("keeps the direct connection with $name, which npm cannot match", ({ lines, noProxy }) => { + assert.deepStrictEqual( + parseScutilProxy( + dictionary(...lines, "HTTPEnable : 1", "HTTPPort : 3128", "HTTPProxy : 10.0.0.1"), + noProxy, + ), + {}, + ); + }); it("reads only the top-level settings and merges exceptions into NO_PROXY", () => { assert.deepStrictEqual( diff --git a/apps/server/src/provider/macosSystemProxy.ts b/apps/server/src/provider/macosSystemProxy.ts index c4c8e4c6a5b20..c2c8e1308a168 100644 --- a/apps/server/src/provider/macosSystemProxy.ts +++ b/apps/server/src/provider/macosSystemProxy.ts @@ -25,7 +25,7 @@ function toNoProxyEntry(exception: string): string { /** * npm matches NO_PROXY by hostname suffix and ignores IP ranges, so a bypassed - * private registry would go through the proxy. Such lists keep main's direct connection. + * private registry would go through the proxy. Such bypass lists keep the direct connection. */ function hasUnrepresentableException(exception: string): boolean { if (LINK_LOCAL_RANGE.test(exception)) return false; @@ -62,14 +62,16 @@ export function parseScutilProxy(output: string, inheritedNoProxy = ""): Record< const http = proxyUrl(fields, "HTTP"); const https = proxyUrl(fields, "HTTPS"); - if ((!http && !https) || exceptions.some(hasUnrepresentableException)) { + const inherited = inheritedNoProxy.split(",").flatMap((entry) => entry.trim() || []); + if ( + (!http && !https) || + // npm cannot match dotless hosts either, so honouring this needs a direct connection. + fields.get("ExcludeSimpleHostnames") === "1" || + [...inherited, ...exceptions].some(hasUnrepresentableException) + ) { return {}; } - const noProxy = [ - ...inheritedNoProxy.split(",").flatMap((entry) => entry.trim() || []), - ...LOOPBACK_NO_PROXY, - ...exceptions.map(toNoProxyEntry), - ].join(","); + const noProxy = [...inherited, ...LOOPBACK_NO_PROXY, ...exceptions.map(toNoProxyEntry)].join(","); return { ...(http ? { HTTP_PROXY: http, http_proxy: http } : {}), ...(https ? { HTTPS_PROXY: https, https_proxy: https } : {}), From 493f48d5ad210b1141bc3e12f84680e4a64ac8b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:25:28 +0000 Subject: [PATCH 7/7] fix(server): only pass a macOS system proxy npm can route like macOS npm sends plain-HTTP requests via https_proxy and falls back to HTTP_PROXY for HTTPS, so a proxy enabled for only one protocol, or different HTTP and HTTPS proxies, would reroute traffic macOS sends direct. Pass the system proxy only when HTTP and HTTPS share one. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/provider/macosSystemProxy.test.ts | 14 ++++++++++---- apps/server/src/provider/macosSystemProxy.ts | 13 ++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/macosSystemProxy.test.ts b/apps/server/src/provider/macosSystemProxy.test.ts index 83904f7282201..7aef7a8f75722 100644 --- a/apps/server/src/provider/macosSystemProxy.test.ts +++ b/apps/server/src/provider/macosSystemProxy.test.ts @@ -40,12 +40,13 @@ describe("parseScutilProxy", () => { }, { name: "an IP range in NO_PROXY", lines: [], noProxy: "registry.corp,127.0.0.0/8" }, { name: "simple hostnames excluded", lines: ["ExcludeSimpleHostnames : 1"], noProxy: "" }, + { name: "an HTTPS-only proxy", lines: ["HTTPEnable : 0"], noProxy: "" }, + { name: "different HTTP and HTTPS proxies", lines: ["HTTPPort : 8080"], noProxy: "" }, ])("keeps the direct connection with $name, which npm cannot match", ({ lines, noProxy }) => { + const proxy = ["HTTPEnable : 1", "HTTPPort : 3128", "HTTPProxy : 10.0.0.1"]; + const httpsProxy = ["HTTPSEnable : 1", "HTTPSPort : 3128", "HTTPSProxy : 10.0.0.1"]; assert.deepStrictEqual( - parseScutilProxy( - dictionary(...lines, "HTTPEnable : 1", "HTTPPort : 3128", "HTTPProxy : 10.0.0.1"), - noProxy, - ), + parseScutilProxy(dictionary(...proxy, ...httpsProxy, ...lines), noProxy), {}, ); }); @@ -59,6 +60,9 @@ describe("parseScutilProxy", () => { " 1 : 169.254/16", " 2 : internal.corp", "}", + "HTTPEnable : 1", + "HTTPPort : 8443", + "HTTPProxy : fd00::1", "HTTPSEnable : 1", "HTTPSPort : 8443", "HTTPSProxy : fd00::1", @@ -73,6 +77,8 @@ describe("parseScutilProxy", () => { "registry.corp, ", ), { + HTTP_PROXY: "http://[fd00::1]:8443", + http_proxy: "http://[fd00::1]:8443", HTTPS_PROXY: "http://[fd00::1]:8443", https_proxy: "http://[fd00::1]:8443", NO_PROXY: "registry.corp,localhost,127.0.0.1,::1,[::1],.local,169.254.0.0/16,internal.corp", diff --git a/apps/server/src/provider/macosSystemProxy.ts b/apps/server/src/provider/macosSystemProxy.ts index c2c8e1308a168..3579082b5747d 100644 --- a/apps/server/src/provider/macosSystemProxy.ts +++ b/apps/server/src/provider/macosSystemProxy.ts @@ -60,11 +60,12 @@ export function parseScutilProxy(output: string, inheritedNoProxy = ""): Record< } } - const http = proxyUrl(fields, "HTTP"); - const https = proxyUrl(fields, "HTTPS"); + const proxy = proxyUrl(fields, "HTTP"); const inherited = inheritedNoProxy.split(",").flatMap((entry) => entry.trim() || []); if ( - (!http && !https) || + // npm sends http requests via https_proxy and https via HTTP_PROXY, so only one shared proxy keeps macOS's routing. + !proxy || + proxy !== proxyUrl(fields, "HTTPS") || // npm cannot match dotless hosts either, so honouring this needs a direct connection. fields.get("ExcludeSimpleHostnames") === "1" || [...inherited, ...exceptions].some(hasUnrepresentableException) @@ -73,8 +74,10 @@ export function parseScutilProxy(output: string, inheritedNoProxy = ""): Record< } const noProxy = [...inherited, ...LOOPBACK_NO_PROXY, ...exceptions.map(toNoProxyEntry)].join(","); return { - ...(http ? { HTTP_PROXY: http, http_proxy: http } : {}), - ...(https ? { HTTPS_PROXY: https, https_proxy: https } : {}), + HTTP_PROXY: proxy, + http_proxy: proxy, + HTTPS_PROXY: proxy, + https_proxy: proxy, NO_PROXY: noProxy, no_proxy: noProxy, };