Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

348 lines
11 KiB
Go

package runtimecmd
import (
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"golang.org/x/net/http/httpproxy"
)
// System proxy injection.
//
// Some standalone agent processes only honor the
// HTTP(S)_PROXY environment variables — they do NOT read the OS system proxy.
// Desktop runtimes commonly work around this by resolving the OS proxy via
// Electron's session.resolveProxy() (Chromium/SystemConfiguration) and injecting
// HTTPS_PROXY/HTTP_PROXY into the spawned process. Without it, a child agent
// connects directly and, from a restricted region, gets `403 Request not
// allowed` from the upstream API while the app keeps working.
//
// We mirror that behavior here through a narrow platform adapter and inject
// equivalent env vars. To stay faithful to the
// app we: skip SOCKS entries (downstream HTTP agents don't speak SOCKS), use the
// same default NO_PROXY, and never override a proxy the user/session already set.
//
// Effective precedence, everywhere (spawned agents and in-process clients):
//
// session-explicit env > process env (incl. the user's shell env, when the
// desktop forwards it) > OS system proxy > direct
//
// Out of scope, deliberately: SOCKS proxies and PAC resolution (scutil only
// exposes only a PAC URL; the desktop's Chromium stack handles PAC natively for
// its own requests). Windows support intentionally covers the static per-user
// proxy only. Setting TUTTI_DISABLE_PROXY_AUTODETECT=1 disables system-proxy
// detection and injection entirely; explicit env proxies keep working.
// noProxyDefault matches the value the Claude desktop app injects.
const noProxyDefault = "localhost,127.0.0.1,::1,.local"
// disableProxyAutodetectEnvKey is the escape hatch for machines where the
// detected system proxy is broken but direct connections work.
const disableProxyAutodetectEnvKey = "TUTTI_DISABLE_PROXY_AUTODETECT"
func proxyAutodetectDisabled() bool {
value := strings.TrimSpace(os.Getenv(disableProxyAutodetectEnvKey))
return value == "1" || strings.EqualFold(value, "true")
}
// injectSystemProxyEnv appends HTTPS_PROXY/HTTP_PROXY/NO_PROXY derived from the
// OS system proxy, but only for keys not already present (case-insensitive)
// in env, so explicit user/session settings always win.
func (r Resolver) injectSystemProxyEnv(env []string) []string {
if proxyAutodetectDisabled() {
return env
}
proxyEnv := r.systemProxyEnv()
if len(proxyEnv) == 0 {
return env
}
for _, key := range []string{"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"} {
value, ok := proxyEnv[key]
if !ok || strings.TrimSpace(value) == "" {
continue
}
if _, exists := envValueFrom(env, key); exists {
continue
}
env = append(env, key+"="+value)
}
return env
}
// InjectSystemProxyEnv appends the OS system proxy variables
// (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) to env for any key not already present,
// reading the same platform system-proxy source as Resolver.Env(). It exists so
// the spawn paths that build their own environment instead of going through
// Resolver.Env() — managed-runtime installs, agent logins, workspace terminals —
// get identical proxy treatment. No-op on unsupported platforms or when no system proxy is
// configured; never overrides a user/session-set value.
func InjectSystemProxyEnv(env []string) []string {
return Resolver{}.injectSystemProxyEnv(env)
}
// DynamicProxyFunc returns an http.Transport.Proxy function that resolves the
// proxy per request: explicit proxies from the process environment win, the
// OS system proxy fills in the blanks, and NO_PROXY (from env, the system
// exceptions, or noProxyDefault) plus loopback targets always bypass. It is the
// in-process counterpart of InjectSystemProxyEnv, so daemon HTTP clients and
// spawned agents make the same routing decision. The system proxy is re-read
// (through a short-lived cache) on every call, so toggling the proxy panel
// takes effect without restarting the daemon.
func DynamicProxyFunc() func(*http.Request) (*url.URL, error) {
return func(req *http.Request) (*url.URL, error) {
cfg := httpproxy.FromEnvironment()
if !proxyAutodetectDisabled() {
mergeSystemProxy(cfg, Resolver{}.systemProxyEnv())
}
return cfg.ProxyFunc()(req.URL)
}
}
// EffectiveProxySummary reports which source currently supplies the outbound
// proxy — "env" (explicit environment variables), "system" (OS system
// proxy), or "none" — plus the proxy address as host:port with any credentials
// stripped. Startup/diagnostic logging only; it answers "did requests on this
// machine go through a proxy" without another capture round-trip.
func EffectiveProxySummary() (source string, host string) {
fromEnv := httpproxy.FromEnvironment()
if raw := firstNonEmptyString(fromEnv.HTTPSProxy, fromEnv.HTTPProxy); raw != "" {
return "env", proxyHostForLog(raw)
}
if proxyAutodetectDisabled() {
return "none", ""
}
sys := Resolver{}.systemProxyEnv()
if raw := firstNonEmptyString(sys["HTTPS_PROXY"], sys["HTTP_PROXY"]); raw != "" {
return "system", proxyHostForLog(raw)
}
return "none", ""
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
// proxyHostForLog reduces a proxy URL to host:port, never exposing userinfo.
func proxyHostForLog(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed == nil || parsed.Host == "" {
// Scheme-less values ("host:port") parse without a Host; retry with an
// explicit scheme rather than logging the raw (possibly credentialed)
// string.
parsed, err = url.Parse("http://" + raw)
if err != nil || parsed == nil {
return ""
}
}
return parsed.Host
}
// SystemProxyEnv exposes the cached OS system proxy as env-style keys
// (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) for observability call sites that want to
// log the effective proxy source. Returns nil when autodetect is disabled or no
// system proxy is configured.
func SystemProxyEnv() map[string]string {
if proxyAutodetectDisabled() {
return nil
}
return Resolver{}.systemProxyEnv()
}
// mergeSystemProxy fills empty fields of an env-derived proxy config with the
// system proxy values, preserving env-first precedence. When any proxy ends up
// configured without a NO_PROXY, noProxyDefault applies so local services are
// never routed through the proxy (httpproxy additionally hard-bypasses
// loopback hosts).
func mergeSystemProxy(cfg *httpproxy.Config, systemEnv map[string]string) {
if cfg.HTTPSProxy == "" {
cfg.HTTPSProxy = systemEnv["HTTPS_PROXY"]
}
if cfg.HTTPProxy == "" {
cfg.HTTPProxy = systemEnv["HTTP_PROXY"]
}
if cfg.NoProxy == "" {
cfg.NoProxy = systemEnv["NO_PROXY"]
}
if cfg.NoProxy == "" && (cfg.HTTPSProxy != "" || cfg.HTTPProxy != "") {
cfg.NoProxy = noProxyDefault
}
}
// systemProxyCache memoizes platform proxy reads so hot paths (per-spawn env
// injection, per-request proxy resolution) avoid native I/O each time, while
// still noticing proxy-panel toggles within the TTL.
var systemProxyCache = struct {
sync.Mutex
env map[string]string
expiresAt time.Time
}{}
const systemProxyCacheTTL = 60 * time.Second
func (r Resolver) systemProxyEnv() map[string]string {
if r.ScutilProxy != nil {
// Injected (test) source: bypass the cache so cases stay independent.
output, ok := r.ScutilProxy()
if !ok {
return nil
}
return parseScutilProxy(output)
}
systemProxyCache.Lock()
defer systemProxyCache.Unlock()
if time.Now().Before(systemProxyCache.expiresAt) {
return systemProxyCache.env
}
env := readSystemProxyEnv()
systemProxyCache.env = env
systemProxyCache.expiresAt = time.Now().Add(systemProxyCacheTTL)
return env
}
// parseScutilProxy turns `scutil --proxy` output into proxy env vars.
//
// Example input:
//
// <dictionary> {
// HTTPSEnable : 1
// HTTPSProxy : 127.0.0.1
// HTTPSPort : 7890
// ...
// }
//
// SOCKS entries are intentionally ignored, and PAC (ProxyAutoConfig) is not
// resolved — scutil only exposes the PAC URL, not the per-URL result, so we
// leave those to the user's explicit env. Returns nil when no usable proxy is
// configured (direct connection).
func parseScutilProxy(output string) map[string]string {
fields := map[string]string{}
for _, line := range strings.Split(output, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
proxyURL := func(enableKey, hostKey, portKey string) string {
if fields[enableKey] != "1" {
return ""
}
host := fields[hostKey]
port := fields[portKey]
if host == "" || port == "" {
return ""
}
return "http://" + net.JoinHostPort(host, port)
}
// Prefer the HTTPS entry (the upstream API is HTTPS); fall back to HTTP.
// Both env vars point at the same proxy, mirroring the Claude app.
url := proxyURL("HTTPSEnable", "HTTPSProxy", "HTTPSPort")
if url == "" {
url = proxyURL("HTTPEnable", "HTTPProxy", "HTTPPort")
}
if url == "" {
return nil
}
return map[string]string{
"HTTPS_PROXY": url,
"HTTP_PROXY": url,
"NO_PROXY": noProxyDefault,
}
}
// parseWindowsProxyServer converts the WinINet ProxyServer registry value into
// env-style HTTP proxy settings. Windows accepts either one proxy for all
// protocols ("host:port") or a protocol map ("http=...;https=...;socks=...").
// SOCKS entries are deliberately ignored because downstream HTTP agents do not
// consistently support SOCKS proxy URLs.
func parseWindowsProxyServer(proxyServer, proxyOverride string) map[string]string {
proxyServer = strings.TrimSpace(proxyServer)
if proxyServer == "" {
return nil
}
values := map[string]string{}
if strings.Contains(proxyServer, "=") {
for _, part := range strings.Split(proxyServer, ";") {
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = normalizeHTTPProxyURL(value)
if value != "" && (key == "http" || key == "https") {
values[key] = value
}
}
} else if value := normalizeHTTPProxyURL(proxyServer); value != "" {
values["http"] = value
values["https"] = value
}
if len(values) == 0 {
return nil
}
if values["https"] == "" {
values["https"] = values["http"]
}
if values["http"] == "" {
values["http"] = values["https"]
}
result := map[string]string{
"HTTPS_PROXY": values["https"],
"HTTP_PROXY": values["http"],
"NO_PROXY": windowsNoProxy(proxyOverride),
}
return result
}
func normalizeHTTPProxyURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if !strings.Contains(value, "://") {
value = "http://" + value
}
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
return parsed.String()
}
func windowsNoProxy(proxyOverride string) string {
items := strings.Split(noProxyDefault, ",")
seen := map[string]struct{}{}
for _, value := range items {
seen[strings.ToLower(value)] = struct{}{}
}
for _, raw := range strings.Split(proxyOverride, ";") {
value := strings.TrimSpace(raw)
if value == "" || strings.EqualFold(value, "<local>") {
continue
}
if strings.HasPrefix(value, "*.") {
value = value[1:]
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
items = append(items, value)
}
return strings.Join(items, ",")
}