Files
yetone 3691c2047d plugin, agent: bun is given a SOCKS proxy through the bridge, so a plugin installs with only a SOCKS system proxy (#1409)
With only a SOCKS system proxy on macOS (127.0.0.1:4401), every plugin
install failed: netproxy.Env put socks5://… in bun add's *_PROXY, and Bun
takes only http:// and https:// proxies, so it stopped with
"UnsupportedProxyProtocol downloading package manifest". The plugin
host's own fetches already went through netproxy.Bridge (hostEnv), but
bunCommand, which runs bun add, update and remove, did not.

- netproxy.EnvForBun rewrites a SOCKS5 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY
  (any case) to the loopback HTTP bridge in front of it; others as they are.
- bunCommand uses it, so add, update, remove and the host's run all get it.
- Sibling: an agent CLI installed with bun is updated by `bun add -g`
  with the same env (updateEnv in cliupdate.go); npm is left as it is.
- provider-plugins.md says so.

Run:
- TestInstallThroughSOCKSProxy (real bun 1.4.2, fake registry, SOCKS
  proxy only): on the old bunCommand it fails with the reporter's exact
  "error: UnsupportedProxyProtocol downloading package manifest"; passes
  with the fix, through the SOCKS proxy.
- TestEnvForBunBridgesSystemSOCKS (system SOCKS proxy via Env) and
  TestUpdateWithBunBridgesSOCKS fail with EnvForBun / the bun branch
  broken ("bun is given …socks5://…"), pass with it.
- Real use: a throwaway test with a real SOCKS5 server and only
  *_PROXY=socks5://… installed @magpie-community/opencode-cursor-auth
  0.2.2 from registry.npmjs.org through it; on the old code the same
  failed as UnsupportedProxyProtocol. Scratch HOME, not committed.
- gofmt -l clean; go vet ./...; GOOS=linux go vet -tags nogui ./...;
  GOOS=windows go vet ./...; go build; GOOS=linux go build -tags nogui;
  GOOS=windows go build; go test -tags nogui ./... (scratch HOME) all ok.
2026-10-09 19:35:35 +08:00

242 lines
7.3 KiB
Go

package netproxy
import (
"encoding/binary"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"sync/atomic"
"testing"
)
// fakeSOCKS is a SOCKS5 proxy taking user u and password pw ("" for none),
// counting the connections it carried.
func fakeSOCKS(t *testing.T, u, pw string) (*url.URL, *atomic.Int32) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
var n atomic.Int32
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
var h [2]byte
io.ReadFull(c, h[:])
ms := make([]byte, h[1])
io.ReadFull(c, ms)
if u != "" {
c.Write([]byte{5, 2})
var v [2]byte
io.ReadFull(c, v[:])
got := make([]byte, v[1])
io.ReadFull(c, got)
var l [1]byte
io.ReadFull(c, l[:])
p := make([]byte, l[0])
io.ReadFull(c, p)
if string(got) != u || string(p) != pw {
c.Write([]byte{1, 1})
return
}
c.Write([]byte{1, 0})
} else {
c.Write([]byte{5, 0})
}
var r [4]byte
io.ReadFull(c, r[:])
var host string
switch r[3] {
case 1:
b := make([]byte, 4)
io.ReadFull(c, b)
host = net.IP(b).String()
case 3:
var l [1]byte
io.ReadFull(c, l[:])
b := make([]byte, l[0])
io.ReadFull(c, b)
host = string(b)
}
var pb [2]byte
io.ReadFull(c, pb[:])
up, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(int(binary.BigEndian.Uint16(pb[:])))))
if err != nil {
c.Write([]byte{5, 5, 0, 1, 0, 0, 0, 0, 0, 0})
return
}
defer up.Close()
n.Add(1)
c.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
go io.Copy(up, c)
io.Copy(c, up)
}(c)
}
}()
s := &url.URL{Scheme: "socks5", Host: ln.Addr().String()}
if u != "" {
s.User = url.UserPassword(u, pw)
}
return s, &n
}
// A plugin's request goes through a SOCKS5 proxy as a built-in's does:
// the bridge carries a plain http:// request and an https:// one's
// CONNECT tunnel through it, signing in to it when it asks.
func TestBridgeCarriesThroughSOCKS(t *testing.T) {
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "plain "+r.URL.Path) }))
defer plain.Close()
tls := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "tls "+r.URL.Path) }))
defer tls.Close()
for _, auth := range []bool{false, true} {
u, pw := "", ""
if auth {
u, pw = "me", "secret"
}
socks, n := fakeSOCKS(t, u, pw)
b, err := Bridge(socks)
if err != nil {
t.Fatal(err)
}
if again, _ := Bridge(socks); again != b {
t.Fatalf("a second bridge %s for the same proxy, not %s", again, b)
}
if ForBun(socks.String()) != b || !strings.HasPrefix(b, "http://127.0.0.1:") {
t.Fatalf("ForBun(%s) = %s, want %s", socks, ForBun(socks.String()), b)
}
pu, _ := url.Parse(b)
tr := tls.Client().Transport.(*http.Transport).Clone()
tr.Proxy = http.ProxyURL(pu)
c := &http.Client{Transport: tr}
for _, x := range []struct{ url, want string }{{plain.URL + "/a", "plain /a"}, {tls.URL + "/b", "tls /b"}} {
res, err := c.Get(x.url)
if err != nil {
t.Fatalf("auth=%v %s: %v", auth, x.url, err)
}
got, _ := io.ReadAll(res.Body)
res.Body.Close()
if string(got) != x.want {
t.Fatalf("auth=%v %s: %q, want %q", auth, x.url, got, x.want)
}
}
if n.Load() != 2 {
t.Fatalf("auth=%v: the SOCKS proxy carried %d connections, want 2", auth, n.Load())
}
}
for _, p := range []string{"http://127.0.0.1:7890", "direct", ""} {
if got := ForBun(p); got != p {
t.Fatalf("ForBun(%q) = %q, want it as it is", p, got)
}
}
}
// A SOCKS proxy that isn't there is said as Go's transport says it, so
// the gateway takes it for the proxy's failure, not the vendor's.
func TestBridgeSaysTheProxyIsDown(t *testing.T) {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close()
b, err := Bridge(&url.URL{Scheme: "socks5h", Host: addr})
if err != nil {
t.Fatal(err)
}
c, err := net.Dial("tcp", strings.TrimPrefix(b, "http://"))
if err != nil {
t.Fatal(err)
}
defer c.Close()
io.WriteString(c, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
got, _ := io.ReadAll(c)
if !strings.HasPrefix(string(got), "HTTP/1.1 502") || !strings.Contains(string(got), "socks connect tcp "+addr) {
t.Fatalf("answered %q", got)
}
}
// Bun itself, the plugin host's runtime, fetches through the bridge: run
// when a bun is at hand ($MAGPIE_BUN or on PATH).
func TestBunThroughBridge(t *testing.T) {
bun := os.Getenv("MAGPIE_BUN")
if bun == "" {
bun, _ = exec.LookPath("bun")
}
if bun == "" {
t.Skip("no bun")
}
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "plain") }))
defer plain.Close()
tls := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "tls") }))
defer tls.Close()
socks, n := fakeSOCKS(t, "me", "pw")
b, _ := Bridge(socks)
// loopback is never proxied by magpie, but the test's servers are on it:
// Bun is given the proxy outright
js := `for (const u of process.env.URLS.split(" ")) console.log(await (await fetch(u, { proxy: "` + b + `", tls: { rejectUnauthorized: false } })).text())`
cmd := exec.Command(bun, "-e", js)
cmd.Env = append(os.Environ(), "URLS="+plain.URL+" "+tls.URL)
out, err := cmd.CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) != "plain\ntls" {
t.Fatalf("bun: %v %s", err, out)
}
if n.Load() != 2 {
t.Fatalf("the SOCKS proxy carried %d connections, want 2", n.Load())
}
}
// A Mac with only a SOCKS system proxy (#1409): what Env gives a bun
// names the bridge in front of it, so bun add doesn't fail as
// UnsupportedProxyProtocol, and the bridge carries through the SOCKS
// proxy. An http:// proxy, and the rest of env, are left as they are.
func TestEnvForBunBridgesSystemSOCKS(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
socks, n := fakeSOCKS(t, "", "")
sysCache.at, sysCache.p = farFuture, Proxy{URL: socks.String()}
t.Cleanup(func() { sysCache.at = zero })
env := EnvForBun(Env([]string{"PATH=/bin", "HOME=/h"}))
var bridge string
for _, kv := range env {
k, v, _ := strings.Cut(kv, "=")
switch strings.ToUpper(k) {
case "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY":
if !strings.HasPrefix(v, "http://127.0.0.1:") || (bridge != "" && v != bridge) {
t.Fatalf("bun is given %s", kv)
}
bridge = v
}
}
if bridge == "" || !slices.Contains(env, "PATH=/bin") || !slices.Contains(env, "HOME=/h") {
t.Fatalf("env: %q", env)
}
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
defer plain.Close()
pu, _ := url.Parse(bridge)
c := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(pu)}}
res, err := c.Get(plain.URL)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if string(b) != "ok" || n.Load() != 1 {
t.Fatalf("through the bridge: %q, the SOCKS proxy carried %d", b, n.Load())
}
httpEnv := []string{"HTTPS_PROXY=http://127.0.0.1:7890", "all_proxy=", "NO_PROXY=localhost"}
if got := EnvForBun(httpEnv); strings.Join(got, "\n") != strings.Join(httpEnv, "\n") {
t.Fatalf("an http proxy: %q", got)
}
}