Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/internal/netproxy/system_proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package netproxy

import "net/url"

func proxyURLFromParts(scheme string, host string, port string) (*url.URL, error) {
if host == "" || port == "" {
return nil, nil
}
return url.Parse(scheme + "://" + host + ":" + port)
}
50 changes: 50 additions & 0 deletions backend/internal/netproxy/system_proxy_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build darwin

package netproxy

import (
"bufio"
"net/http"
"net/url"
"os/exec"
"strings"
)

func resolveSystemProxy(req *http.Request) (*url.URL, error) {
cmd := exec.Command("scutil", "--proxy")
output, err := cmd.Output()
if err != nil {
return nil, nil
}
values := parseScutilProxyOutput(string(output))
if strings.EqualFold(req.URL.Scheme, "https") {
if proxyURL, err := proxyFromScutil(values, "HTTPS"); proxyURL != nil || err != nil {
return proxyURL, err
}
}
return proxyFromScutil(values, "HTTP")
}

func parseScutilProxyOutput(raw string) map[string]string {
values := map[string]string{}
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.Contains(line, " : ") {
continue
}
parts := strings.SplitN(line, " : ", 2)
if len(parts) != 2 {
continue
}
values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
return values
}

func proxyFromScutil(values map[string]string, prefix string) (*url.URL, error) {
if values[prefix+"Enable"] != "1" {
return nil, nil
}
return proxyURLFromParts("http", values[prefix+"Proxy"], values[prefix+"Port"])
}
12 changes: 12 additions & 0 deletions backend/internal/netproxy/system_proxy_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !darwin && !windows

package netproxy

import (
"net/http"
"net/url"
)

func resolveSystemProxy(req *http.Request) (*url.URL, error) {
return nil, nil
}
62 changes: 62 additions & 0 deletions backend/internal/netproxy/system_proxy_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//go:build windows

package netproxy

import (
"net/http"
"net/url"
"strings"

"golang.org/x/sys/windows/registry"
)

func resolveSystemProxy(req *http.Request) (*url.URL, error) {
key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.QUERY_VALUE)
if err != nil {
return nil, nil
}
defer key.Close()

enabled, _, err := key.GetIntegerValue("ProxyEnable")
if err != nil || enabled == 0 {
return nil, nil
}
server, _, err := key.GetStringValue("ProxyServer")
if err != nil {
return nil, nil
}
return parseWindowsProxyServer(req, server)
}

func parseWindowsProxyServer(req *http.Request, raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
entries := strings.Split(raw, ";")
perScheme := map[string]string{}
defaultEntry := ""
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.Contains(entry, "=") {
parts := strings.SplitN(entry, "=", 2)
perScheme[strings.ToLower(strings.TrimSpace(parts[0]))] = strings.TrimSpace(parts[1])
continue
}
defaultEntry = entry
}
selected := defaultEntry
if value, ok := perScheme[strings.ToLower(req.URL.Scheme)]; ok {
selected = value
}
if selected == "" {
return nil, nil
}
if strings.Contains(selected, "://") {
return url.Parse(selected)
}
return url.Parse("http://" + selected)
}
21 changes: 21 additions & 0 deletions backend/internal/netproxy/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ type settingsReader interface {
GetAppSettings() (settings.AppSettings, error)
}

var systemProxyResolver = resolveSystemProxy

func SetSystemProxyResolverForTest(resolver func(*http.Request) (*url.URL, error)) func() {
previous := systemProxyResolver
if resolver == nil {
systemProxyResolver = resolveSystemProxy
} else {
systemProxyResolver = resolver
}
return func() {
systemProxyResolver = previous
}
}

func NewHTTPClient(repo settingsReader) *http.Client {
transport := defaultTransportClone()
transport.Proxy = func(req *http.Request) (*url.URL, error) {
Expand Down Expand Up @@ -44,6 +58,13 @@ func ResolveProxy(req *http.Request, repo settingsReader) (*url.URL, error) {
}
return proxyURL, nil
case "", settings.UpstreamProxyModeSystem:
proxyURL, err := systemProxyResolver(req)
if err != nil {
return nil, err
}
if proxyURL != nil {
return proxyURL, nil
}
return http.ProxyFromEnvironment(req)
default:
return http.ProxyFromEnvironment(req)
Expand Down
56 changes: 56 additions & 0 deletions backend/internal/netproxy/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package netproxy_test

import (
"net/http"
"net/url"
"testing"

"github.com/gcssloop/codex-router/backend/internal/netproxy"
Expand Down Expand Up @@ -61,3 +62,58 @@ func TestResolveProxyUsesManualMode(t *testing.T) {
t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://user:pass@127.0.0.1:7890")
}
}

func TestResolveProxyUsesSystemProxyResolver(t *testing.T) {
t.Parallel()

restore := netproxy.SetSystemProxyResolverForTest(func(req *http.Request) (*url.URL, error) {
return url.Parse("http://127.0.0.1:7897")
})
defer restore()

reader := stubSettingsReader{value: settings.AppSettings{
UpstreamProxyMode: settings.UpstreamProxyModeSystem,
}}
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("NewRequest returned error: %v", err)
}

got, err := netproxy.ResolveProxy(req, reader)
if err != nil {
t.Fatalf("ResolveProxy returned error: %v", err)
}
if got == nil {
t.Fatal("ResolveProxy = nil, want system proxy URL")
}
if got.String() != "http://127.0.0.1:7897" {
t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://127.0.0.1:7897")
}
}

func TestResolveProxySystemModeFallsBackToEnvironment(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
restore := netproxy.SetSystemProxyResolverForTest(func(req *http.Request) (*url.URL, error) {
return nil, nil
})
defer restore()

reader := stubSettingsReader{value: settings.AppSettings{
UpstreamProxyMode: settings.UpstreamProxyModeSystem,
}}
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatalf("NewRequest returned error: %v", err)
}

got, err := netproxy.ResolveProxy(req, reader)
if err != nil {
t.Fatalf("ResolveProxy returned error: %v", err)
}
if got == nil {
t.Fatal("ResolveProxy = nil, want environment proxy URL")
}
if got.String() != "http://127.0.0.1:8888" {
t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://127.0.0.1:8888")
}
}
79 changes: 79 additions & 0 deletions docs/plans/2026-03-28-system-proxy-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# System Proxy Resolution Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make `system` upstream proxy mode read the actual OS system proxy on macOS and Windows instead of only using environment variables.

**Architecture:** Keep the existing `netproxy` transport as the single proxy decision point. Add a platform-aware system proxy resolver behind `ResolveProxy`, with Linux and unsupported environments falling back to `http.ProxyFromEnvironment`.

**Tech Stack:** Go, standard library `net/http`, platform commands on macOS, Windows registry / command helpers, existing backend tests.

---

### Task 1: Add failing tests for system proxy resolution

**Files:**
- Modify: `backend/internal/netproxy/transport_test.go`

**Step 1: Write failing tests**
- Add a test proving `system` mode uses an injected system proxy resolver result instead of environment variables.
- Add a test proving `system` mode falls back to environment variables when system proxy lookup returns nothing.

**Step 2: Run tests to verify failure**
Run: `cd backend && go test ./internal/netproxy -run 'TestResolveProxyUsesSystemProxyResolver|TestResolveProxySystemModeFallsBackToEnvironment'`
Expected: FAIL because no injectable system proxy resolver exists yet.

**Step 3: Commit after green**
```bash
git add backend/internal/netproxy/transport_test.go backend/internal/netproxy/*.go
git commit -m "feat(netproxy): resolve system proxy from os settings"
```

### Task 2: Implement platform-aware system proxy resolution

**Files:**
- Modify: `backend/internal/netproxy/transport.go`
- Create: `backend/internal/netproxy/system_proxy.go`
- Create: `backend/internal/netproxy/system_proxy_darwin.go`
- Create: `backend/internal/netproxy/system_proxy_windows.go`
- Create: `backend/internal/netproxy/system_proxy_other.go`

**Step 1: Add resolver abstraction**
- Introduce a package-level `systemProxyResolver` function variable for test injection.
- Keep `ResolveProxy` as the only public decision point.

**Step 2: Implement system mode behavior**
- In `system` mode, try OS proxy lookup first.
- If system lookup yields a valid proxy URL, return it.
- If lookup is unavailable or empty, fall back to `http.ProxyFromEnvironment`.

**Step 3: Implement macOS resolver**
- Read the active network service proxy via `scutil --proxy`.
- Support HTTP and HTTPS proxies.
- Respect enabled flags and host/port presence.
- Prefer HTTPS proxy for `https` requests.

**Step 4: Implement Windows resolver**
- Read `ProxyEnable` and `ProxyServer` from `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`.
- Support both per-scheme and single proxy formats.
- Prefer HTTPS proxy for `https` requests.

**Step 5: Implement fallback resolver for unsupported platforms**
- Return nil so Linux/others keep environment behavior.

### Task 3: Verify integrated behavior

**Files:**
- Modify only if needed: existing tests

**Step 1: Run targeted backend tests**
Run: `cd backend && go test ./internal/netproxy ./internal/usagedrv/... ./internal/bootstrap`
Expected: PASS

**Step 2: Run broader API/provider smoke tests**
Run: `cd backend && go test ./internal/api ./internal/providers/...`
Expected: PASS

**Step 3: Review diff**
Run: `git diff --stat`
Expected: only netproxy-related implementation and tests.
27 changes: 18 additions & 9 deletions frontend/src/features/accounts/AccountsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1829,7 +1829,7 @@ describe("AccountsPage", () => {
expect(screen.getByText("90%")).toBeInTheDocument();
});

it("formats official reset windows with time for 5H and date for 7D", async () => {
it("formats official reset windows with explicit time for both 5H and 7D", async () => {
const now = new Date();
const primaryReset = new Date(now.getTime() + 60 * 60 * 1000);
const secondaryReset = new Date(now.getTime() + 2 * 60 * 60 * 1000);
Expand Down Expand Up @@ -1926,12 +1926,20 @@ describe("AccountsPage", () => {
element?.textContent === primaryResetDateTime,
),
).toBeInTheDocument();
const secondaryResetTime = secondaryReset.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const secondaryResetDateTime = `${secondaryReset.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
})} ${secondaryResetTime}`;
expect(
screen.getByText(
secondaryReset.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
}),
(_content, element) =>
element?.textContent === secondaryResetTime ||
element?.textContent === secondaryResetDateTime,
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "详情-official-main" }));
Expand All @@ -1948,12 +1956,13 @@ describe("AccountsPage", () => {
element?.textContent === detailPrimaryDateTimeValue,
),
).toBeInTheDocument();
const detailSecondaryValue = `33% · ${secondaryResetTime}`;
const detailSecondaryDateTimeValue = `33% · ${secondaryResetDateTime}`;
expect(
within(detailModal).getByText(
`33% · ${secondaryReset.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
})}`,
(_content, element) =>
element?.textContent === detailSecondaryValue ||
element?.textContent === detailSecondaryDateTimeValue,
),
).toBeInTheDocument();
});
Expand Down
Loading
Loading