Skip to content

Commit 5d43a71

Browse files
committed
feat(cli): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY behind corporate and CI proxies
1 parent e53257d commit 5d43a71

5 files changed

Lines changed: 93 additions & 41 deletions

File tree

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"license": "Apache-2.0",
5151
"dependencies": {
5252
"commander": "^12.1.0",
53+
"undici": "^8.5.0",
5354
"valibot": "^1.4.1"
5455
},
5556
"devDependencies": {

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createTestCommand } from './commands/test.js';
1212
import { createUsageCommand } from './commands/usage.js';
1313
import { ApiError, CLIError, RequestTimeoutError } from './lib/errors.js';
1414
import { Output, isOutputMode } from './lib/output.js';
15+
import { maybeInstallProxyAgent } from './lib/proxy.js';
1516
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
1617
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
1718
import { VERSION } from './version.js';
@@ -138,6 +139,10 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
138139
});
139140
});
140141

142+
// Corporate/CI proxies: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (Node's fetch
143+
// ignores them by default). No-op when no proxy variable is set.
144+
maybeInstallProxyAgent();
145+
141146
try {
142147
await program.parseAsync(process.argv);
143148
} catch (err) {

src/lib/proxy.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { maybeInstallProxyAgent } from './proxy.js';
3+
4+
describe('maybeInstallProxyAgent', () => {
5+
it('installs an agent when HTTPS_PROXY is set', () => {
6+
const install = vi.fn();
7+
const installed = maybeInstallProxyAgent({
8+
env: { HTTPS_PROXY: 'http://proxy.corp.example.com:8080' },
9+
install,
10+
});
11+
expect(installed).toBe(true);
12+
expect(install).toHaveBeenCalledTimes(1);
13+
});
14+
15+
it.each(['https_proxy', 'HTTP_PROXY', 'http_proxy'] as const)(
16+
'also honors the %s spelling',
17+
name => {
18+
const install = vi.fn();
19+
const installed = maybeInstallProxyAgent({
20+
env: { [name]: 'http://proxy.corp.example.com:8080' },
21+
install,
22+
});
23+
expect(installed).toBe(true);
24+
expect(install).toHaveBeenCalledTimes(1);
25+
},
26+
);
27+
28+
it('does nothing when no proxy variable is set (default path unchanged)', () => {
29+
const install = vi.fn();
30+
expect(maybeInstallProxyAgent({ env: {}, install })).toBe(false);
31+
expect(maybeInstallProxyAgent({ env: { HTTPS_PROXY: '' }, install })).toBe(false);
32+
expect(install).not.toHaveBeenCalled();
33+
});
34+
});

src/lib/proxy.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Proxy support (issue #119): honor HTTPS_PROXY / HTTP_PROXY / NO_PROXY.
3+
*
4+
* Node's built-in fetch (undici) deliberately ignores the proxy environment
5+
* variables, so behind a corporate or CI proxy every request dies with
6+
* `fetch failed` after a full retry cycle. Installing undici's
7+
* `EnvHttpProxyAgent` as the global dispatcher restores the conventional
8+
* behavior (including NO_PROXY exemptions) for every fetch the CLI makes.
9+
*
10+
* Only active when a proxy env var is actually present, so the default path
11+
* stays byte-identical and pays zero startup cost. Dependency note for
12+
* reviewers: this adds `undici` as an explicit runtime dependency (the same
13+
* engine Node already bundles); the alternative, a hand-rolled CONNECT
14+
* tunnel, would re-implement what undici ships and maintains.
15+
*/
16+
import type { Dispatcher } from 'undici';
17+
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
18+
19+
export interface ProxyDeps {
20+
env?: NodeJS.ProcessEnv;
21+
/** Dispatcher installer. Defaults to undici's setGlobalDispatcher. */
22+
install?: (agent: Dispatcher) => void;
23+
}
24+
25+
/**
26+
* Install the env-driven proxy dispatcher when any proxy variable is set
27+
* (both canonical upper-case and conventional lower-case spellings).
28+
* Returns whether an agent was installed (observable for tests/debugging).
29+
*/
30+
export function maybeInstallProxyAgent(deps: ProxyDeps = {}): boolean {
31+
const env = deps.env ?? process.env;
32+
const hasProxy = [env.HTTPS_PROXY, env.https_proxy, env.HTTP_PROXY, env.http_proxy].some(
33+
value => typeof value === 'string' && value.length > 0,
34+
);
35+
if (!hasProxy) return false;
36+
const install = deps.install ?? setGlobalDispatcher;
37+
// EnvHttpProxyAgent reads HTTPS_PROXY/HTTP_PROXY/NO_PROXY itself, per
38+
// request, so NO_PROXY exemptions apply without extra plumbing here.
39+
install(new EnvHttpProxyAgent());
40+
return true;
41+
}

0 commit comments

Comments
 (0)