Skip to content

Commit e224b0f

Browse files
committed
fix: honor NO_PROXY, support http proxy, trim proxy env vars
1 parent 2c3fd28 commit e224b0f

6 files changed

Lines changed: 210 additions & 11 deletions

File tree

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/providers/__tests__/bedrock.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ vi.mock("@smithy/node-http-handler", () => ({
2626
NodeHttpHandler: vi.fn(),
2727
}))
2828

29+
vi.mock("http-proxy-agent", () => ({
30+
HttpProxyAgent: vi.fn(),
31+
}))
32+
2933
vi.mock("https-proxy-agent", () => ({
3034
HttpsProxyAgent: vi.fn(),
3135
}))
@@ -60,13 +64,15 @@ import {
6064
import type { Anthropic } from "@anthropic-ai/sdk"
6165
import { getSystemProxyUrl } from "../../../utils/networkProxy"
6266
import { NodeHttpHandler } from "@smithy/node-http-handler"
67+
import { HttpProxyAgent } from "http-proxy-agent"
6368
import { HttpsProxyAgent } from "https-proxy-agent"
6469

6570
// Get access to the mocked functions
6671
const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand)
6772
const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient)
6873
const mockGetSystemProxyUrl = vi.mocked(getSystemProxyUrl)
6974
const mockNodeHttpHandler = vi.mocked(NodeHttpHandler)
75+
const mockHttpProxyAgent = vi.mocked(HttpProxyAgent)
7076
const mockHttpsProxyAgent = vi.mocked(HttpsProxyAgent)
7177

7278
describe("AwsBedrockHandler", () => {
@@ -141,7 +147,7 @@ describe("AwsBedrockHandler", () => {
141147
mockGetSystemProxyUrl.mockReturnValue(undefined)
142148
})
143149

144-
it("should configure NodeHttpHandler with HttpsProxyAgent when proxy URL is set", () => {
150+
it("should configure NodeHttpHandler with HttpProxyAgent and HttpsProxyAgent when proxy URL is set", () => {
145151
mockGetSystemProxyUrl.mockReturnValue("http://proxy.corp.local:3128")
146152

147153
new AwsBedrockHandler({
@@ -151,13 +157,20 @@ describe("AwsBedrockHandler", () => {
151157
awsRegion: "us-east-1",
152158
})
153159

160+
// Verify both proxy agents were created with the correct URL
161+
expect(mockHttpProxyAgent).toHaveBeenCalledWith("http://proxy.corp.local:3128")
154162
expect(mockHttpsProxyAgent).toHaveBeenCalledWith("http://proxy.corp.local:3128")
163+
164+
// Verify NodeHttpHandler was created with both agents
155165
expect(mockNodeHttpHandler).toHaveBeenCalledWith(
156166
expect.objectContaining({
167+
httpAgent: expect.anything(),
157168
httpsAgent: expect.anything(),
158169
requestTimeout: 0,
159170
}),
160171
)
172+
173+
// Verify requestHandler was set on BedrockRuntimeClient config
161174
expect(mockBedrockRuntimeClient).toHaveBeenLastCalledWith(
162175
expect.objectContaining({ requestHandler: expect.anything() }),
163176
)
@@ -192,6 +205,30 @@ describe("AwsBedrockHandler", () => {
192205
}),
193206
)
194207
})
208+
209+
it("should pass a custom endpoint to getSystemProxyUrl for NO_PROXY matching", () => {
210+
new AwsBedrockHandler({
211+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
212+
awsAccessKey: "test-access-key",
213+
awsSecretKey: "test-secret-key",
214+
awsRegion: "us-east-1",
215+
awsBedrockEndpoint: "https://bedrock.vpce.internal",
216+
awsBedrockEndpointEnabled: true,
217+
})
218+
219+
expect(mockGetSystemProxyUrl).toHaveBeenCalledWith("https://bedrock.vpce.internal")
220+
})
221+
222+
it("should pass undefined to getSystemProxyUrl when no custom endpoint is set", () => {
223+
new AwsBedrockHandler({
224+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
225+
awsAccessKey: "test-access-key",
226+
awsSecretKey: "test-secret-key",
227+
awsRegion: "us-east-1",
228+
})
229+
230+
expect(mockGetSystemProxyUrl).toHaveBeenCalledWith(undefined)
231+
})
195232
})
196233

197234
describe("region mapping and cross-region inference", () => {

src/api/providers/bedrock.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { NodeHttpHandler } from "@smithy/node-http-handler"
1414
import OpenAI from "openai"
1515
import { fromIni } from "@aws-sdk/credential-providers"
1616
import { Anthropic } from "@anthropic-ai/sdk"
17+
import { HttpProxyAgent } from "http-proxy-agent"
1718
import { HttpsProxyAgent } from "https-proxy-agent"
1819

1920
import {
@@ -298,11 +299,19 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
298299
}
299300

300301
// When a corporate proxy is configured, Node resolves DNS locally before tunneling,
301-
// causing ENOTFOUND for endpoints that only the proxy can reach. HttpsProxyAgent
302-
// uses CONNECT tunneling so the proxy handles DNS resolution instead.
303-
const proxyUrl = getSystemProxyUrl()
302+
// causing ENOTFOUND for endpoints that only the proxy can reach. HttpProxyAgent and
303+
// HttpsProxyAgent use CONNECT tunneling so the proxy handles DNS resolution instead.
304+
//
305+
// A custom endpoint (e.g. a VPC endpoint) is passed so NO_PROXY can bypass the proxy
306+
// for directly-reachable hosts. For the default managed endpoint we don't reconstruct
307+
// the hostname (the AWS SDK resolves it internally, and it varies by partition), so the
308+
// proxy always applies there.
309+
const proxyUrl = getSystemProxyUrl(
310+
typeof clientConfig.endpoint === "string" ? clientConfig.endpoint : undefined,
311+
)
304312
if (proxyUrl) {
305313
clientConfig.requestHandler = new NodeHttpHandler({
314+
httpAgent: new HttpProxyAgent(proxyUrl),
306315
httpsAgent: new HttpsProxyAgent(proxyUrl),
307316
requestTimeout: 0,
308317
})

src/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@
487487
"get-folder-size": "^5.0.0",
488488
"global-agent": "^3.0.0",
489489
"google-auth-library": "^10.2.0",
490+
"http-proxy-agent": "^7.0.0",
490491
"https-proxy-agent": "^7.0.0",
491492
"gray-matter": "^4.0.3",
492493
"i18next": "^25.0.0",

src/utils/__tests__/networkProxy.spec.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,15 @@ describe("networkProxy", () => {
308308

309309
describe("getSystemProxyUrl", () => {
310310
beforeEach(() => {
311-
// Clear env vars before each test
311+
vi.clearAllMocks()
312+
// Clear all proxy env vars and VS Code setting before each test
312313
delete process.env.HTTPS_PROXY
313314
delete process.env.https_proxy
314315
delete process.env.HTTP_PROXY
315316
delete process.env.http_proxy
317+
delete process.env.NO_PROXY
318+
delete process.env.no_proxy
319+
mockConfig.get.mockReturnValue(undefined)
316320
})
317321

318322
it("should return proxy from HTTPS_PROXY env var", () => {
@@ -368,5 +372,101 @@ describe("networkProxy", () => {
368372
const result = getSystemProxyUrl()
369373
expect(result).toBe("http://env-proxy:3128")
370374
})
375+
376+
it("should trim whitespace from env var proxy values", () => {
377+
process.env.HTTPS_PROXY = " http://proxy.corp:3128 "
378+
const result = getSystemProxyUrl()
379+
expect(result).toBe("http://proxy.corp:3128")
380+
})
381+
382+
it("should reject whitespace-only proxy values", () => {
383+
process.env.HTTPS_PROXY = " "
384+
const result = getSystemProxyUrl()
385+
expect(result).toBeUndefined()
386+
})
387+
388+
it("should skip empty env var and try next fallback", () => {
389+
process.env.HTTP_PROXY = " http://http-proxy:3128 "
390+
const result = getSystemProxyUrl()
391+
expect(result).toBe("http://http-proxy:3128")
392+
})
393+
394+
it("should use VS Code setting when all env vars are empty", () => {
395+
mockConfig.get.mockReturnValue(" http://vscode-proxy:8080 ")
396+
const result = getSystemProxyUrl()
397+
expect(result).toBe("http://vscode-proxy:8080")
398+
})
399+
400+
describe("NO_PROXY handling", () => {
401+
it("should bypass proxy when NO_PROXY exactly matches the target host", () => {
402+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
403+
process.env.NO_PROXY = "bedrock.vpce.internal"
404+
const result = getSystemProxyUrl("https://bedrock.vpce.internal")
405+
expect(result).toBeUndefined()
406+
})
407+
408+
it("should bypass proxy when NO_PROXY is a domain suffix of the target host", () => {
409+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
410+
process.env.NO_PROXY = "amazonaws.com"
411+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
412+
expect(result).toBeUndefined()
413+
})
414+
415+
it("should bypass proxy for all hosts when NO_PROXY is '*'", () => {
416+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
417+
process.env.NO_PROXY = "*"
418+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
419+
expect(result).toBeUndefined()
420+
})
421+
422+
it("should use the proxy when NO_PROXY does not match the target host", () => {
423+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
424+
process.env.NO_PROXY = "example.com"
425+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
426+
expect(result).toBe("http://proxy.corp:3128")
427+
})
428+
429+
it("should handle leading dot and trailing port in NO_PROXY entries", () => {
430+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
431+
process.env.NO_PROXY = ".amazonaws.com:443"
432+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
433+
expect(result).toBeUndefined()
434+
})
435+
436+
it("should match one entry out of a comma-separated NO_PROXY list", () => {
437+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
438+
process.env.NO_PROXY = "example.com, amazonaws.com, other.net"
439+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
440+
expect(result).toBeUndefined()
441+
})
442+
443+
it("should use the proxy when no entry in a comma-separated NO_PROXY list matches", () => {
444+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
445+
process.env.NO_PROXY = "example.com, foo.net, other.org"
446+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
447+
expect(result).toBe("http://proxy.corp:3128")
448+
})
449+
450+
it("should also bypass the VS Code proxy when NO_PROXY matches", () => {
451+
process.env.NO_PROXY = "amazonaws.com"
452+
mockConfig.get.mockReturnValue("http://vscode-proxy:8080")
453+
const result = getSystemProxyUrl("https://bedrock-runtime.us-east-1.amazonaws.com")
454+
expect(result).toBeUndefined()
455+
})
456+
457+
it("should ignore NO_PROXY when no target URL is provided", () => {
458+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
459+
process.env.NO_PROXY = "*"
460+
const result = getSystemProxyUrl()
461+
expect(result).toBe("http://proxy.corp:3128")
462+
})
463+
464+
it("should not bypass when target URL is malformed", () => {
465+
process.env.HTTPS_PROXY = "http://proxy.corp:3128"
466+
process.env.NO_PROXY = "amazonaws.com"
467+
const result = getSystemProxyUrl("not-a-valid-url")
468+
expect(result).toBe("http://proxy.corp:3128")
469+
})
470+
})
371471
})
372472
})

src/utils/networkProxy.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -346,17 +346,66 @@ export function isDebugMode(): boolean {
346346
return extensionContext.extensionMode === vscode.ExtensionMode.Development
347347
}
348348

349+
/**
350+
* Determine whether a target URL should bypass the proxy based on NO_PROXY / no_proxy.
351+
*
352+
* Follows the widely-used convention (curl, proxy-from-env):
353+
* - `NO_PROXY=*` bypasses the proxy for every host.
354+
* - Entries are comma-separated host suffixes; an optional leading `.` or `*.` and a
355+
* trailing `:port` are ignored. A target matches when its hostname equals an entry
356+
* or ends with `.<entry>` (so `amazonaws.com` covers `bedrock-runtime.us-east-1.amazonaws.com`).
357+
* - Matching is case-insensitive.
358+
*/
359+
function isNoProxyHost(targetUrl: string): boolean {
360+
const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "").trim()
361+
if (!noProxy) return false
362+
if (noProxy === "*") return true
363+
364+
let hostname: string
365+
try {
366+
hostname = new URL(targetUrl).hostname.toLowerCase()
367+
} catch {
368+
return false
369+
}
370+
if (!hostname) return false
371+
372+
return noProxy
373+
.split(",")
374+
.map((entry) => entry.trim().toLowerCase())
375+
.filter(Boolean)
376+
.some((entry) => {
377+
// Normalize a leading wildcard/dot and a trailing port: "*.example.com:443" -> "example.com"
378+
const suffix = entry.replace(/^\*?\./, "").replace(/:\d+$/, "")
379+
if (!suffix) return false
380+
return hostname === suffix || hostname.endsWith(`.${suffix}`)
381+
})
382+
}
383+
349384
/**
350385
* Get the proxy URL from environment variables or VS Code settings.
351386
* Works in all extension modes (production and debug).
387+
*
388+
* When `targetUrl` is provided and its host is covered by NO_PROXY / no_proxy, this
389+
* returns undefined so the caller connects directly — honoring the user's intent to
390+
* bypass the proxy for e.g. a directly-reachable VPC or AWS endpoint.
391+
*
392+
* @param targetUrl Optional URL of the request destination, used for NO_PROXY matching.
393+
* @returns The proxy URL, or undefined if no proxy applies or only whitespace is set.
352394
*/
353-
export function getSystemProxyUrl(): string | undefined {
354-
// Standard proxy environment variables (HTTPS takes precedence over HTTP)
395+
export function getSystemProxyUrl(targetUrl?: string): string | undefined {
396+
// If the destination is covered by NO_PROXY, connect directly regardless of proxy source.
397+
if (targetUrl && isNoProxyHost(targetUrl)) {
398+
return undefined
399+
}
400+
401+
// Standard proxy environment variables (HTTPS takes precedence over HTTP).
402+
// Trim to reject whitespace-only values which would be invalid for HttpsProxyAgent.
355403
const fromEnv =
356-
process.env.HTTPS_PROXY ||
357-
process.env.https_proxy ||
358-
process.env.HTTP_PROXY ||
359-
process.env.http_proxy
404+
process.env.HTTPS_PROXY?.trim() ||
405+
process.env.https_proxy?.trim() ||
406+
process.env.HTTP_PROXY?.trim() ||
407+
process.env.http_proxy?.trim()
408+
360409
if (fromEnv) return fromEnv
361410

362411
// Fall back to VS Code's http.proxy setting

0 commit comments

Comments
 (0)