Skip to content

Commit e8ef3cb

Browse files
eric-engbergsirmalloc
authored andcommitted
fix: connect directly to hosts NO_PROXY excludes
The usage and Claude Status requests used HTTPS_PROXY whatever NO_PROXY said. The usage request carries the account's OAuth token, and a user who keeps api.anthropic.com off a proxy (for example a TLS-inspecting corporate one) had it sent through that proxy anyway. Both requests now connect directly when NO_PROXY (or no_proxy) lists the host or a domain it's in: `*`, `anthropic.com`, `.anthropic.com` or `*.anthropic.com`, optionally with `:443`. HTTPS_PROXY itself is still read in uppercase only, as documented.
1 parent 4c62e31 commit e8ef3cb

8 files changed

Lines changed: 145 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ The interactive configuration tool provides a terminal UI where you can:
364364
> export CLAUDE_CONFIG_DIR=/custom/path/to/.claude
365365
> ```
366366
367-
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic.
367+
> 🌐 **Usage API proxy:** Usage widgets honor the uppercase `HTTPS_PROXY` environment variable for their direct API call to Anthropic, and connect directly when `NO_PROXY` lists `api.anthropic.com` or a domain it's in.
368368
369369
> 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md).
370370

‎docs/DEVELOPMENT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Usage-lock deadlines more than 24 hours ahead are treated as poisoned and ignore
7878
- **Transcript-backed widgets** stream the active JSONL transcript once per render through `getTranscriptAnalysis()`, collecting token, duration, speed, compaction, thinking-effort, and session-name data in one pass without materializing the whole file. Referenced subagent transcripts are streamed separately only when speed metrics include subagents.
7979
- **Block Timer** caches a detected block until its five-hour window expires. When a full scan finds no active block, that empty result is cached for one minute so subsequent repaints do not repeatedly walk and read the entire transcript history.
8080
- **Cache Timer** reads the transcript tail directly on every render. It expands the read backward when a trailing JSONL record exceeds the initial window, ignores sidechain and synthetic API-error rows, and anchors the countdown only on assistant requests with cache activity. It does not create a separate cache file.
81-
- **Claude Status** reads `status.claude.com` through HTTPS, honors `HTTPS_PROXY`, and caches successful responses for five minutes. It requests incident data only when at least one configured Claude Status widget enables history, applies a 30-second backoff after failed fetches, and serves a usable stale cache when available.
81+
- **Claude Status** reads `status.claude.com` through HTTPS, honors `HTTPS_PROXY` unless `NO_PROXY` excludes the host, and caches successful responses for five minutes. It requests incident data only when at least one configured Claude Status widget enables history, applies a 30-second backoff after failed fetches, and serves a usable stale cache when available.
8282
- **Local Git widgets** cache command results in memory and under `~/.cache/ccstatusline/git-cache`. Cache misses invoke Git with a five-second timeout; failures, including timeouts, are cached as `null`. Persistent writes use one stable `.tmp` path per cache file and attempt best-effort cleanup on failure, bounding orphaned files when Windows virus scanners or sync clients temporarily hold a handle.
8383
- **Git PR and Git CI Status** render from the versioned disk cache under `~/.cache/ccstatusline/git-review`. Missing or stale entries are refreshed in a detached helper so network-bound `gh` or `glab` calls do not block rendering. Git CI Status adds GitHub's `statusCheckRollup`; if the authenticated `gh` token cannot read checks, the refresh retries with PR metadata only so Git PR still works.
8484
- **Usage widgets** merge Claude Code's stdin `rate_limits` with `/api/oauth/usage` only for fields required by the active widgets. Session and aggregate weekly fields prefer the flat API buckets and fall back to `limits[]`; per-model weekly fields prefer `weekly_scoped` entries. A model-scoped entry reporting 0% without `resets_at` is valid zero usage, while unscoped empty placeholders remain filtered out. `WEEKLY_MODEL_USAGE_BUCKETS` in `src/utils/usage-types.ts` is the shared registry for Sonnet, Opus, and Fable widget wiring, field requirements, reset fields, and scoped-limit matching. Session and weekly percentage widgets delegate rendering and editor behavior to `src/widgets/shared/usage-percent-widget.ts`; the Fable label is `Weekly Fable:`.

‎src/utils/__tests__/claude-service-status.test.ts‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,37 @@ type StatusPageRequestFn = NonNullable<Parameters<typeof __testing.fetchStatusPa
2727
const HOUR_MS = 60 * 60 * 1000;
2828
const NOW = Date.parse('2026-08-15T12:00:00Z');
2929

30-
// Tests set HTTPS_PROXY themselves; the environment running the suite must not leak in.
30+
// Tests set HTTPS_PROXY and NO_PROXY themselves; the environment running the
31+
// suite must not leak in.
3132
function isolateHttpsProxyEnv(): void {
32-
let original: string | undefined;
33+
let originalProxy: string | undefined;
34+
let originalNoProxy: string | undefined;
35+
let originalLowercaseNoProxy: string | undefined;
3336

3437
beforeEach(() => {
35-
original = process.env.HTTPS_PROXY;
38+
originalProxy = process.env.HTTPS_PROXY;
39+
originalNoProxy = process.env.NO_PROXY;
40+
originalLowercaseNoProxy = process.env.no_proxy;
3641
delete process.env.HTTPS_PROXY;
42+
delete process.env.NO_PROXY;
43+
delete process.env.no_proxy;
3744
});
3845

3946
afterEach(() => {
40-
if (original === undefined) {
41-
delete process.env.HTTPS_PROXY;
42-
} else {
43-
process.env.HTTPS_PROXY = original;
44-
}
47+
restoreEnv('HTTPS_PROXY', originalProxy);
48+
restoreEnv('NO_PROXY', originalNoProxy);
49+
restoreEnv('no_proxy', originalLowercaseNoProxy);
4550
});
4651
}
4752

53+
function restoreEnv(name: 'HTTPS_PROXY' | 'NO_PROXY' | 'no_proxy', value: string | undefined): void {
54+
if (value === undefined) {
55+
Reflect.deleteProperty(process.env, name);
56+
} else {
57+
process.env[name] = value;
58+
}
59+
}
60+
4861
function incident(impact: ClaudeIncidentWindow['impact'], startHoursAgo: number, endHoursAgo: number | null): ClaudeIncidentWindow {
4962
return {
5063
impact,
@@ -279,6 +292,15 @@ describe('status page response handling', () => {
279292
expect((agent as HttpsProxyAgent<string>).proxy.href).toBe('http://proxy.example:8080/');
280293
});
281294

295+
it('connects directly when NO_PROXY lists status.claude.com', async () => {
296+
process.env.HTTPS_PROXY = 'http://proxy.example:8080';
297+
process.env.NO_PROXY = 'localhost,claude.com';
298+
const requestFn = vi.fn(respondingRequest(200, ['{}']));
299+
300+
await expect(__testing.fetchStatusPagePath('/test', requestFn)).resolves.toBe('{}');
301+
expect(requestFn.mock.calls[0]?.[0]).not.toHaveProperty('agent');
302+
});
303+
282304
it('ignores a whitespace-only HTTPS_PROXY', async () => {
283305
process.env.HTTPS_PROXY = ' ';
284306
const requestFn = vi.fn(respondingRequest(200, ['{}']));
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
describe,
3+
expect,
4+
it
5+
} from 'vitest';
6+
7+
import { isExcludedFromProxy } from '../no-proxy';
8+
9+
describe('isExcludedFromProxy', () => {
10+
it.each([
11+
['*'],
12+
['api.anthropic.com'],
13+
['anthropic.com'],
14+
['.anthropic.com'],
15+
['*.anthropic.com'],
16+
['API.Anthropic.COM'],
17+
['api.anthropic.com:443'],
18+
['localhost, api.anthropic.com'],
19+
['localhost,,anthropic.com'],
20+
['localhost anthropic.com']
21+
])('excludes api.anthropic.com for NO_PROXY=%j', (noProxy) => {
22+
expect(isExcludedFromProxy('api.anthropic.com', { NO_PROXY: noProxy })).toBe(true);
23+
});
24+
25+
it.each([
26+
[''],
27+
['localhost'],
28+
['example.com'],
29+
['pi.anthropic.com'],
30+
['notanthropic.com'],
31+
['api.anthropic.com:8443'],
32+
['.']
33+
])('keeps api.anthropic.com on the proxy for NO_PROXY=%j', (noProxy) => {
34+
expect(isExcludedFromProxy('api.anthropic.com', { NO_PROXY: noProxy })).toBe(false);
35+
});
36+
37+
it('reads the lowercase no_proxy too', () => {
38+
expect(isExcludedFromProxy('status.claude.com', { no_proxy: 'claude.com' })).toBe(true);
39+
});
40+
41+
it('excludes nothing when neither variable is set', () => {
42+
expect(isExcludedFromProxy('status.claude.com', {})).toBe(false);
43+
});
44+
});

‎src/utils/__tests__/usage-fetch.test.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ interface ProbeOptions {
4444
httpsProxy?: string;
4545
lockWrittenDuringRequest?: string;
4646
lowercaseHttpsProxy?: string;
47+
noProxy?: string;
4748
mode?: 'error' | 'status' | 'success' | 'unexpected';
4849
nowMs: number;
4950
pathDir?: string;
@@ -207,7 +208,8 @@ process.stdout.write(JSON.stringify({
207208
const normalizedKey = key.toUpperCase();
208209
return normalizedKey !== 'CLAUDE_CONFIG_DIR'
209210
&& normalizedKey !== 'CLAUDE_SECURESTORAGE_CONFIG_DIR'
210-
&& normalizedKey !== 'HTTPS_PROXY';
211+
&& normalizedKey !== 'HTTPS_PROXY'
212+
&& normalizedKey !== 'NO_PROXY';
211213
}));
212214

213215
Object.assign(env, {
@@ -241,6 +243,10 @@ process.stdout.write(JSON.stringify({
241243
env.https_proxy = options.lowercaseHttpsProxy;
242244
}
243245

246+
if (options.noProxy !== undefined) {
247+
env.NO_PROXY = options.noProxy;
248+
}
249+
244250
const output = realExecFileSync(process.execPath, [probeScriptPath], {
245251
encoding: 'utf8',
246252
env
@@ -430,6 +436,34 @@ describe('fetchUsageData error handling', () => {
430436
}
431437
});
432438

439+
// The request carries the account's bearer token, so a host the user kept
440+
// off the proxy must stay off it
441+
it('connects directly when NO_PROXY lists api.anthropic.com', () => {
442+
const harness = createProbeHarness();
443+
444+
try {
445+
const noProxyHome = harness.createTokenHome('no-proxy');
446+
447+
const result = harness.runProbe({
448+
claudeConfigDir: noProxyHome.claudeConfig,
449+
home: noProxyHome.home,
450+
httpsProxy: 'http://proxy.local:8080',
451+
noProxy: 'localhost,.anthropic.com',
452+
mode: 'success',
453+
nowMs,
454+
pathDir: noProxyHome.bin,
455+
responseBody: successResponseBody
456+
});
457+
458+
expect(result.first).toMatchObject({ sessionUsage: 42, weeklyUsage: 17 });
459+
expect(result.requestCount).toBe(1);
460+
expect(result.proxyAgentConfigured).toBe(false);
461+
expect(result.requestHost).toBe('api.anthropic.com');
462+
} finally {
463+
harness.cleanup();
464+
}
465+
});
466+
433467
it('preserves root errors within a process and keeps existing proxy and cache behavior', () => {
434468
const harness = createProbeHarness();
435469

‎src/utils/claude-service-status.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ColorLevelString } from '../types/ColorLevel';
99
import type { WidgetItem } from '../types/Widget';
1010

1111
import { getColorAnsiCode } from './colors';
12+
import { isExcludedFromProxy } from './no-proxy';
1213

1314
// Cache configuration mirrors usage-fetch.ts: a short-lived disk cache shared
1415
// across statusline invocations, plus a failure lock so an unreachable status
@@ -259,7 +260,7 @@ function clearFailureLock(): void {
259260

260261
function getStatusPageProxyUrl(): string | null {
261262
const proxyUrl = process.env.HTTPS_PROXY?.trim();
262-
return proxyUrl?.length ? proxyUrl : null;
263+
return proxyUrl?.length && !isExcludedFromProxy(STATUS_HOST) ? proxyUrl : null;
263264
}
264265

265266
interface StatusPageResponse {

‎src/utils/no-proxy.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Whether NO_PROXY (or no_proxy) keeps HTTPS requests to `host` off the proxy:
3+
* `*`, the host itself, or a domain it's in (`anthropic.com`, `.anthropic.com`
4+
* or `*.anthropic.com`), each optionally with `:443`. Both spellings are read,
5+
* since honoring an exclusion only ever sends less through the proxy.
6+
*/
7+
export function isExcludedFromProxy(host: string, env: NodeJS.ProcessEnv = process.env): boolean {
8+
const target = host.toLowerCase();
9+
const entries = [env.NO_PROXY, env.no_proxy]
10+
.filter((value): value is string => typeof value === 'string')
11+
.flatMap(value => value.split(/[\s,]+/));
12+
13+
return entries.some((rawEntry) => {
14+
let entry = rawEntry.toLowerCase();
15+
const portStart = entry.lastIndexOf(':');
16+
if (portStart !== -1) {
17+
if (entry.slice(portStart + 1) !== '443') {
18+
return false;
19+
}
20+
entry = entry.slice(0, portStart);
21+
}
22+
23+
if (entry === '*') {
24+
return true;
25+
}
26+
27+
const domain = entry.replace(/^\*?\./, '');
28+
return domain !== '' && (target === domain || target.endsWith(`.${domain}`));
29+
});
30+
}

‎src/utils/usage-fetch.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as path from 'node:path';
88
import { z } from 'zod';
99

1010
import { getClaudeConfigDir } from './claude-settings';
11+
import { isExcludedFromProxy } from './no-proxy';
1112
import type {
1213
UsageData,
1314
UsageDataField,
@@ -765,7 +766,7 @@ const USAGE_API_TIMEOUT_MS = 5000;
765766

766767
function getUsageApiProxyUrl(): string | null {
767768
const proxyUrl = process.env.HTTPS_PROXY?.trim();
768-
if (proxyUrl === '') {
769+
if (proxyUrl === '' || isExcludedFromProxy(USAGE_API_HOST)) {
769770
return null;
770771
}
771772

0 commit comments

Comments
 (0)