Skip to content

Agent host applies the CAPI proxy to all Copilot CLI requests, breaking MCP to PAC-DIRECT hosts #337239

Description

  • VS Code Version: 1.138.0 (commit 7debcd0e2acdea1c52de81bf9ee1620444407dda)
  • OS Version: Linux x64
  • Does this issue occur when all extensions are disabled?: Yes (the agent host and the bundled Copilot CLI are built in)

Summary

When the OS proxy is configured through a PAC script and no explicit proxy settings or proxy environment
variables are present, the agent host resolves the proxy for a single URL — api.githubcopilot.com,
or whatever resolveApiEndpoint() returns — and writes that one result into the Copilot CLI subprocess as
HTTP_PROXY/HTTPS_PROXY. Every HTTP request that subprocess makes is then forced through a proxy chosen
only for an external endpoint, including MCP requests to hosts the PAC script explicitly maps to
DIRECT
.

Relationship to #326185 / #333614 — this is the remaining case, not a duplicate

I can confirm #333614 works as intended on 1.138.0. On the same machine, same day:

1.136.1   Proxy configuration: HTTP_PROXY=..., HTTPS_PROXY=..., NO_PROXY=(not set)
1.138.0   Proxy configuration: HTTP_PROXY=..., HTTPS_PROXY=..., NO_PROXY=localhost,127.0.0.1,*.corp.example,...

http.noProxy is now forwarded, and _readNoProxy() additionally falls back to an inherited
no_proxy/NO_PROXY. That fixed our symptom once we set http.noProxy explicitly.

What remains is the path #333614 deliberately left in place — "OS/PAC discovery remains the final
fallback"
. On that path there is no bypass list to forward, because PAC does not expose one (see
below), so a single collapsed proxy value is still applied process-wide. A clean machine with only a PAC
and no explicit settings therefore still fails on 1.138.0.

Why PAC makes this incorrect by construction

PAC has no bypass list. FindProxyForURL(url, host) answers per host, and the answer may depend on
dnsResolve(), myIpAddress() or arbitrary JavaScript. Collapsing one host's answer into a process-wide
value discards the function's entire purpose — and there is no list that could be forwarded as NO_PROXY
instead.

An anonymised PAC equivalent to the one in our environment:

function isLocalDestination(host, hostIP) {
    return isPlainHostName(host) ||
        isInNet(hostIP, "10.0.0.0", "255.0.0.0") ||
        isInNet(hostIP, "172.16.0.0", "255.240.0.0") ||
        isInNet(hostIP, "192.168.0.0", "255.255.0.0") ||
        shExpMatch(host, "*.corp.example");
}

function FindProxyForURL(url, host) {
    if (isLocalDestination(host, dnsResolve(host))) return "DIRECT";
    return "PROXY proxy-external.corp.example:3128";
}

What it returns for the two hosts that matter:

Target PAC answer
api.githubcopilot.com PROXY proxy-external.corp.example:3128
internal-mcp.corp.example DIRECT — matches *.corp.example, and its address is in 10.0.0.0/8

The subprocess receives HTTP_PROXY=http://proxy-external.corp.example:3128 and sends the internal
request there. That proxy has no route into the internal network, so the request times out. The PAC's
DIRECT answer for the internal host is never queried on this code path.

Steps to Reproduce

All of the following must hold — on 1.138.0 any one of them is enough to mask the problem:

  1. OS proxy in PAC / auto mode, where the PAC returns DIRECT for an internal host and a proxy for
    api.githubcopilot.com.
  2. The client IP must not match any blanket-DIRECT rule inside the PAC.
  3. http.proxy unset — otherwise _readConfiguredProxy() short-circuits the PAC path.
  4. http.noProxy unset, and no inherited no_proxy/NO_PROXY — otherwise _readNoProxy() masks it.
  5. No inherited HTTP_PROXY/HTTPS_PROXY/ALL_PROXY (any case) — otherwise the agent host leaves proxy
    configuration to the environment.
  6. Register a streamable-HTTP MCP server pointing at the internal host in .mcp.json, then open an Agent
    window session in that workspace.

Actual behaviour

First line of the Copilot CLI log (~/.copilot/logs/*.log) on every start:

[INFO] Proxy configuration: HTTP_PROXY=http://proxy-external.corp.example:3128,
       HTTPS_PROXY=http://proxy-external.corp.example:3128, NO_PROXY=(not set)

The MCP connection then fails:

[ERROR] Failed to start MCP client for <server>: failed to initialize MCP client:
  Send message error Transport [rmcp::transport::worker::WorkerTransport<
  rmcp::transport::streamable_http_client::StreamableHttpClientWorker<...>>]
  error: Client error: error sending request for url
  (https://internal-mcp.corp.example/mcp/), when send initialize request

The VS Code log confirms the injection:

[info] [Copilot] Resolved CAPI proxy and forwarded HTTP_PROXY/HTTPS_PROXY to Copilot SDK

/proc/<cli-pid>/environ shows HTTP_PROXY/HTTPS_PROXY present, while the VS Code main process has
no proxy variables at all — confirming these values are injected by VS Code rather than inherited
from the desktop session.

Expected behaviour

The request to the internal host should follow the PAC's DIRECT answer for that host.

Evidence: three-way comparison

Same machine, same endpoint, curl only, isolating the proxy variables:

HTTP_PROXY NO_PROXY Result
proxy-external.corp.example:3128 unset timeout (curl 28, 20 s) — reproduces the failure
proxy-external.corp.example:3128 *.corp.example 200, direct connection
an internal-capable proxy unset 200, via proxy

Row 1 is exactly the environment the agent host constructs on the PAC path. Rows 2 and 3 are the two
independent ways out, which is what isolates the cause: the failure is not "a proxy is in the way", it is
"a proxy chosen for a different host was applied to this one".

Root cause (code as of 1.138.0)

src/vs/platform/agentHost/node/copilot/copilotAgent.ts:

async _resolveProxyForSdk(e = process.env) {
  let t = this._readConfiguredProxy();
  if (t) return t;                                     // http.proxy wins
  if (!this._isSystemProxyEnabled()) return;
  if (lD.some(o => e[o])) { /* leave it to the environment */ return }
  let r = e.VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE || O5; // O5 = https://api.githubcopilot.com
  if (this._githubCredentials.token) { /* resolveApiEndpoint() may replace r */ }
  return await this._proxyResolver.resolveProxy(r);    // ← resolved for ONE url
}

_createCopilotCliEnvironment() {
  let e = this._readConfiguredProxy() ?? (this._isSystemProxyEnabled() ? this._resolvedProxy : void 0);
  ...
  if (e) { for (let a of j5) o[a] = e; ... }            // ← j5 = ["HTTP_PROXY","HTTPS_PROXY"], applied globally
  t && (o.NO_PROXY = t);                               // added by #333614; undefined on the PAC path
}

The intent looks narrow — the log message says "to Copilot SDK" — but the mechanism is a process-wide
environment variable, so the effective scope is every HTTP client in that subprocess. That includes the
MCP client, which is Rust reqwest and reads only environment variables; it cannot see VS Code settings.

#324209 (milestone 1.128.0) introduced this injection.

Suggested fix

In decreasing order of fidelity:

  1. Pass the PAC URL / auto-detect configuration to the subprocess and let its HTTP client resolve per
    request, preserving PAC semantics.
  2. Evaluate the PAC for the hosts the subprocess is actually configured to reach (e.g. MCP server hosts
    from .mcp.json) and forward the ones resolving to DIRECT as NO_PROXY.
  3. Do not derive a process-wide proxy from PAC resolution at all — scope the CAPI proxy to the SDK's CAPI
    client, leaving other clients to the OS configuration.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agent-hostIssues for the agent-host implementation (not the protocol)

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions