Skip to content

Commit 768da46

Browse files
authored
feat(cli): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY behind corporate and CI proxies (#169)
* feat(cli): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY behind corporate and CI proxies * fix(proxy): degrade to default dispatcher when proxy agent init fails instead of crashing startup * fix(proxy): pin undici to ^7.16.0 for Node 20 compatibility (8.x requires Node >=22.19)
1 parent 8eb4acf commit 768da46

5 files changed

Lines changed: 135 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": "^7.16.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
@@ -13,6 +13,7 @@ import { createTestCommand } from './commands/test.js';
1313
import { createUsageCommand } from './commands/usage.js';
1414
import { ApiError, CLIError, RequestTimeoutError } from './lib/errors.js';
1515
import { Output, isOutputMode } from './lib/output.js';
16+
import { maybeInstallProxyAgent } from './lib/proxy.js';
1617
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
1718
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
1819
import { VERSION } from './version.js';
@@ -149,6 +150,10 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
149150
});
150151
});
151152

153+
// Corporate/CI proxies: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (Node's fetch
154+
// ignores them by default). No-op when no proxy variable is set.
155+
maybeInstallProxyAgent();
156+
152157
try {
153158
await program.parseAsync(process.argv);
154159
} catch (err) {

src/lib/proxy.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
35+
it('falls back (returns false, warns, never throws) when installing the agent fails', () => {
36+
// A malformed/unsupported proxy value makes the agent throw at startup; the
37+
// CLI must degrade to a proxy-less dispatcher, not crash every command.
38+
const errs: string[] = [];
39+
const installed = maybeInstallProxyAgent({
40+
env: { HTTPS_PROXY: 'http://proxy.corp.example.com:8080' },
41+
install: () => {
42+
throw new Error('unsupported proxy scheme');
43+
},
44+
stderr: line => errs.push(line),
45+
});
46+
expect(installed).toBe(false);
47+
expect(errs.join('\n')).toContain('ignoring proxy environment');
48+
});
49+
50+
it('still installs when NO_PROXY is set (exemptions are applied per request by undici)', () => {
51+
const install = vi.fn();
52+
const installed = maybeInstallProxyAgent({
53+
env: { HTTPS_PROXY: 'http://proxy.corp.example.com:8080', NO_PROXY: 'localhost,127.0.0.1' },
54+
install,
55+
});
56+
expect(installed).toBe(true);
57+
expect(install).toHaveBeenCalledTimes(1);
58+
});
59+
});

src/lib/proxy.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
/** Warning sink. Defaults to `process.stderr`. */
24+
stderr?: (line: string) => void;
25+
}
26+
27+
/**
28+
* Install the env-driven proxy dispatcher when any proxy variable is set
29+
* (both canonical upper-case and conventional lower-case spellings).
30+
* Returns whether an agent was installed (observable for tests/debugging).
31+
*/
32+
export function maybeInstallProxyAgent(deps: ProxyDeps = {}): boolean {
33+
const env = deps.env ?? process.env;
34+
const hasProxy = [env.HTTPS_PROXY, env.https_proxy, env.HTTP_PROXY, env.http_proxy].some(
35+
value => typeof value === 'string' && value.length > 0,
36+
);
37+
if (!hasProxy) return false;
38+
const install = deps.install ?? setGlobalDispatcher;
39+
// EnvHttpProxyAgent reads HTTPS_PROXY/HTTP_PROXY/NO_PROXY itself, per
40+
// request, so NO_PROXY exemptions apply without extra plumbing here.
41+
//
42+
// A malformed or unsupported proxy value (e.g. `socks5://...`) makes the
43+
// agent throw. Because this runs at startup, an unguarded throw would abort
44+
// every command before the CLI's own error handling — so fall back to the
45+
// default (proxy-less) dispatcher and warn instead of crashing.
46+
try {
47+
install(new EnvHttpProxyAgent());
48+
return true;
49+
} catch (error) {
50+
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
51+
stderr(
52+
`warning: ignoring proxy environment (could not initialize proxy agent): ${
53+
error instanceof Error ? error.message : String(error)
54+
}`,
55+
);
56+
return false;
57+
}
58+
}

0 commit comments

Comments
 (0)