@rikcodes/teamclaude 1.1.13-rik.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
package/src/updater.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Opt-out-able self-update, in the spirit of Claude Code's auto-updater.
|
|
2
|
+
//
|
|
3
|
+
// We ONLY ever touch a global npm install (`npm install -g @karpeleslab/teamclaude`):
|
|
4
|
+
// - a git checkout (a `.git` at the package root) is a dev tree — never touched;
|
|
5
|
+
// - a local dependency / npx copy is left alone (we only notify).
|
|
6
|
+
// Checks hit the npm registry at most once a day (cached in a small file next to
|
|
7
|
+
// the config), so the overwhelmingly common invocation does zero network I/O.
|
|
8
|
+
// Disable entirely with TEAMCLAUDE_DISABLE_AUTOUPDATE=1 or config.autoUpdate=false.
|
|
9
|
+
//
|
|
10
|
+
// Every side-effecting dependency (fetch, spawn, the clock, the cache path) is
|
|
11
|
+
// injectable so the logic is unit-testable without network or npm.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { getConfigPath } from './config.js';
|
|
19
|
+
|
|
20
|
+
export const PKG_NAME = '@rikcodes/teamclaude'; // fork: self-updates track this scope, never upstream's
|
|
21
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
22
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** Package root = one directory above this file's src/ directory. */
|
|
25
|
+
function packageRoot() {
|
|
26
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Installed version, read from the shipped package.json (null if unreadable). */
|
|
30
|
+
export function currentVersion(root = packageRoot()) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version || null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Numeric compare of x.y.z, then the pre-release tail. >0 if a is newer.
|
|
39
|
+
* The tail matters here: this fork versions releases as X.Y.Z-rik.N on the
|
|
40
|
+
* same upstream base, so ignoring it would make every -rik.N publish compare
|
|
41
|
+
* equal and never trigger an update. Ordering follows semver: base segments
|
|
42
|
+
* first, a release outranks any pre-release of the same base, and two
|
|
43
|
+
* pre-releases compare segment-wise (numerically where both are numbers). */
|
|
44
|
+
export function compareVersions(a, b) {
|
|
45
|
+
const parse = (v) => {
|
|
46
|
+
const [base, ...pre] = String(v).split('+')[0].split('-');
|
|
47
|
+
return { nums: base.split('.').map((n) => parseInt(n, 10) || 0), pre: pre.join('-') };
|
|
48
|
+
};
|
|
49
|
+
const pa = parse(a), pb = parse(b);
|
|
50
|
+
for (let i = 0; i < 3; i++) {
|
|
51
|
+
const d = (pa.nums[i] || 0) - (pb.nums[i] || 0);
|
|
52
|
+
if (d) return d;
|
|
53
|
+
}
|
|
54
|
+
if (!pa.pre || !pb.pre) return (pa.pre ? 0 : 1) - (pb.pre ? 0 : 1);
|
|
55
|
+
const sa = pa.pre.split('.'), sb = pb.pre.split('.');
|
|
56
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
57
|
+
if (sa[i] === undefined) return -1;
|
|
58
|
+
if (sb[i] === undefined) return 1;
|
|
59
|
+
const na = Number(sa[i]), nb = Number(sb[i]);
|
|
60
|
+
const d = Number.isFinite(na) && Number.isFinite(nb) ? na - nb : sa[i].localeCompare(sb[i]);
|
|
61
|
+
if (d) return d;
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `npm root -g` (the global modules dir), or null if npm is unavailable. */
|
|
67
|
+
function npmGlobalRoot() {
|
|
68
|
+
try {
|
|
69
|
+
const r = spawnSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000 });
|
|
70
|
+
if (r.status === 0 && r.stdout) return r.stdout.trim();
|
|
71
|
+
} catch { /* npm missing */ }
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How this copy was installed: 'git', 'global', 'local', or 'unknown'. */
|
|
76
|
+
export function installKind({ root = packageRoot(), globalRoot = npmGlobalRoot } = {}) {
|
|
77
|
+
if (existsSync(join(root, '.git'))) return 'git';
|
|
78
|
+
const norm = root.split('\\').join('/');
|
|
79
|
+
if (!norm.includes('/node_modules/')) return 'unknown';
|
|
80
|
+
const g = typeof globalRoot === 'function' ? globalRoot() : globalRoot;
|
|
81
|
+
if (g && norm.startsWith(g.split('\\').join('/'))) return 'global';
|
|
82
|
+
return 'local';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fetch the registry's current "latest" version (null on any failure/timeout). */
|
|
86
|
+
export async function fetchLatestVersion({ fetchImpl = fetch, timeoutMs = 5000 } = {}) {
|
|
87
|
+
const ctrl = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetchImpl(`${REGISTRY}/${PKG_NAME}`, {
|
|
91
|
+
signal: ctrl.signal,
|
|
92
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' }, // abbreviated packument (small, has dist-tags)
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) return null;
|
|
95
|
+
const json = await res.json();
|
|
96
|
+
return json['dist-tags']?.latest || null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultCacheFile() {
|
|
105
|
+
return join(dirname(getConfigPath()), 'update-check.json');
|
|
106
|
+
}
|
|
107
|
+
async function readCache(path) {
|
|
108
|
+
try { return JSON.parse(await readFile(path, 'utf8')); } catch { return {}; }
|
|
109
|
+
}
|
|
110
|
+
async function writeCache(path, obj) {
|
|
111
|
+
try { await writeFile(path, JSON.stringify(obj)); } catch { /* best effort */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Throttled version check. Returns { current, latest, updateAvailable } or null
|
|
116
|
+
* if the version is unknown / the registry couldn't be reached and nothing is
|
|
117
|
+
* cached. Only fetches when the cached check is older than `intervalMs` (or
|
|
118
|
+
* `force`), so back-to-back invocations do no network I/O.
|
|
119
|
+
*/
|
|
120
|
+
export async function checkForUpdate({
|
|
121
|
+
current = currentVersion(),
|
|
122
|
+
cachePath = defaultCacheFile(),
|
|
123
|
+
fetchImpl = fetch,
|
|
124
|
+
now = Date.now(),
|
|
125
|
+
intervalMs = DAY_MS,
|
|
126
|
+
force = false,
|
|
127
|
+
} = {}) {
|
|
128
|
+
if (!current) return null;
|
|
129
|
+
const cache = await readCache(cachePath);
|
|
130
|
+
let latest = cache.latest || null;
|
|
131
|
+
const fresh = cache.checkedAt && (now - cache.checkedAt) < intervalMs;
|
|
132
|
+
if (force || !fresh) {
|
|
133
|
+
const fetched = await fetchLatestVersion({ fetchImpl });
|
|
134
|
+
if (fetched) latest = fetched;
|
|
135
|
+
await writeCache(cachePath, { checkedAt: now, latest });
|
|
136
|
+
}
|
|
137
|
+
if (!latest) return null;
|
|
138
|
+
return { current, latest, updateAvailable: compareVersions(latest, current) > 0 };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Install a specific version globally. Returns true on success. */
|
|
142
|
+
export function runUpdate(version = 'latest', { spawnImpl = spawnSync } = {}) {
|
|
143
|
+
const r = spawnImpl('npm', ['install', '-g', `${PKG_NAME}@${version}`], {
|
|
144
|
+
stdio: 'inherit',
|
|
145
|
+
timeout: 180000,
|
|
146
|
+
});
|
|
147
|
+
return !!r && !r.error && r.status === 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The automatic path used at startup / session-end. Skips dev checkouts and
|
|
152
|
+
* respects the opt-out; when an update exists it silently installs it for a
|
|
153
|
+
* global install, or just prints a one-line notice otherwise. Cheap in the
|
|
154
|
+
* common case: the expensive `npm root -g` probe only runs when an update is
|
|
155
|
+
* actually available.
|
|
156
|
+
*/
|
|
157
|
+
export async function autoUpdate({ config = {}, force = false, log = console.error } = {}) {
|
|
158
|
+
const root = packageRoot();
|
|
159
|
+
if (existsSync(join(root, '.git'))) return { skipped: 'git' }; // dev checkout — never touch
|
|
160
|
+
if (process.env.TEAMCLAUDE_DISABLE_AUTOUPDATE || config.autoUpdate === false) {
|
|
161
|
+
return { skipped: 'disabled' };
|
|
162
|
+
}
|
|
163
|
+
const info = await checkForUpdate({ force });
|
|
164
|
+
if (!info) return { skipped: 'check-failed' };
|
|
165
|
+
if (!info.updateAvailable) return { ...info, upToDate: true };
|
|
166
|
+
|
|
167
|
+
if (installKind({ root }) !== 'global') {
|
|
168
|
+
log(`[TeamClaude] Update available: ${info.current} → ${info.latest}. Run: teamclaude update`);
|
|
169
|
+
return { ...info, notified: true };
|
|
170
|
+
}
|
|
171
|
+
log(`[TeamClaude] Updating ${info.current} → ${info.latest}…`);
|
|
172
|
+
const ok = runUpdate(info.latest);
|
|
173
|
+
log(ok
|
|
174
|
+
? `[TeamClaude] Updated to ${info.latest}. Restart teamclaude to use the new version.`
|
|
175
|
+
: `[TeamClaude] Auto-update failed. Run manually: npm install -g ${PKG_NAME}@latest`);
|
|
176
|
+
return { ...info, updated: ok };
|
|
177
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Zero-dependency `fetch` shim that routes upstream requests through the sx.org
|
|
2
|
+
// proxy when it is enabled. With sx disabled it IS global fetch (byte-for-byte
|
|
3
|
+
// the same behavior), so the default path is unchanged.
|
|
4
|
+
//
|
|
5
|
+
// Node's global fetch can't use a CONNECT proxy without `undici` (a dependency —
|
|
6
|
+
// and "zero dependencies" is a project feature), so when sx is enabled we issue
|
|
7
|
+
// the request with `https.request` over a tunneled TLS socket and return a small
|
|
8
|
+
// object exposing exactly the fetch-Response surface src/server.js relies on:
|
|
9
|
+
// `status`, `headers.get()/.entries()`, `text()`, `arrayBuffer()`, and `body`
|
|
10
|
+
// (a web ReadableStream, so streamResponse()'s getReader()/cancel() is untouched).
|
|
11
|
+
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import https from 'node:https';
|
|
14
|
+
import { ReadableStream } from 'node:stream/web';
|
|
15
|
+
import { tunnelTls } from './sx.js';
|
|
16
|
+
import { proxyForHost, proxyAgent } from './upstream-proxy.js';
|
|
17
|
+
|
|
18
|
+
// Pooled keep-alive agents for the direct (non-sx) path. Node's global fetch
|
|
19
|
+
// multiplexes ALL requests to an origin over a SINGLE HTTP/2 connection; under
|
|
20
|
+
// many concurrent large uploads (Claude Code POSTs ~1MB of context per turn)
|
|
21
|
+
// that one connection serializes on HTTP/2's shared flow-control windows —
|
|
22
|
+
// api.anthropic.com advertises maxConcurrentStreams=100 (not the limit) but only
|
|
23
|
+
// a 64KB initial window, so concurrent uploads queue behind WINDOW_UPDATEs and a
|
|
24
|
+
// trivial request can wait minutes for headers (issue #106). Independent HTTP/1.1
|
|
25
|
+
// connections have no application-layer flow control: each upload fills its own
|
|
26
|
+
// socket at TCP speed, exactly like N direct Claude Code processes. maxSockets is
|
|
27
|
+
// per-origin and bounds the fan-out. Escape hatch:
|
|
28
|
+
// TEAMCLAUDE_UPSTREAM_GLOBAL_FETCH=1 reverts to the old global-fetch path.
|
|
29
|
+
const MAX_SOCKETS = Number(process.env.TEAMCLAUDE_UPSTREAM_MAX_SOCKETS) || 256;
|
|
30
|
+
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: MAX_SOCKETS });
|
|
31
|
+
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: MAX_SOCKETS });
|
|
32
|
+
const USE_GLOBAL_FETCH = /^(1|true|yes|on)$/i.test(process.env.TEAMCLAUDE_UPSTREAM_GLOBAL_FETCH || '');
|
|
33
|
+
|
|
34
|
+
// Time to wait for RESPONSE HEADERS before treating the upstream socket as dead.
|
|
35
|
+
// This is NOT a limit on the response body (SSE completions can stream for
|
|
36
|
+
// minutes); the deadline is cleared the instant headers arrive, so a slow, long
|
|
37
|
+
// answer is never cut. It measures time-to-first-byte only, which streaming
|
|
38
|
+
// delivers within seconds, and its job is to convert an indefinite hang on a
|
|
39
|
+
// half-dead pooled socket (e.g. after the host's network drops and reconnects,
|
|
40
|
+
// leaving Node's global fetch pool holding stale keep-alive connections) into a
|
|
41
|
+
// fast, retryable failure. Without it a reused dead socket hangs until Node's
|
|
42
|
+
// 300s default, long past the point the client gave up, and only a full process
|
|
43
|
+
// restart clears the poisoned pool. Each aborted request evicts one dead socket,
|
|
44
|
+
// so a burst of stale connections drains over the next few retries.
|
|
45
|
+
//
|
|
46
|
+
// NOTE (non-streaming requests): for a request without `stream: true`, the whole
|
|
47
|
+
// response arrives as the "headers+body" unit, so first-byte ≈ full generation.
|
|
48
|
+
// Claude Code's completions stream, so this is safe in practice, but a very long
|
|
49
|
+
// non-streaming generation could trip this — raise it per-call or via the env var
|
|
50
|
+
// for such callers. Mid-stream stalls (a drop AFTER headers) are handled
|
|
51
|
+
// separately by the body-idle watchdog in server.js's streamResponse.
|
|
52
|
+
//
|
|
53
|
+
// We abort ONLY in the pre-headers window and clear the timer once the body
|
|
54
|
+
// starts, so we never abort mid-stream. That matters: an AbortSignal fired after
|
|
55
|
+
// data has started leaves the socket occupied and leaks a "zombie" connection
|
|
56
|
+
// that drains the pool over time; aborting before the first byte lets undici
|
|
57
|
+
// destroy the socket cleanly instead. The textbook fix is dispatcher-level
|
|
58
|
+
// timeouts via undici's setGlobalDispatcher(new Agent({ headersTimeout,
|
|
59
|
+
// keepAliveTimeout })); we stay zero-dependency, so this reactive guard is the
|
|
60
|
+
// stand-in.
|
|
61
|
+
//
|
|
62
|
+
// Default is generous (well above Claude's realistic first-byte, even when
|
|
63
|
+
// queued or under load) so a slow-but-legitimate response is never mistaken for
|
|
64
|
+
// a dead socket. Override with TEAMCLAUDE_UPSTREAM_HEADERS_TIMEOUT_MS (or
|
|
65
|
+
// per-call opts).
|
|
66
|
+
const DEFAULT_HEADERS_TIMEOUT_MS = 120_000;
|
|
67
|
+
|
|
68
|
+
function resolveHeadersTimeout(perCall) {
|
|
69
|
+
if (perCall != null) return perCall;
|
|
70
|
+
const env = Number(process.env.TEAMCLAUDE_UPSTREAM_HEADERS_TIMEOUT_MS);
|
|
71
|
+
return env > 0 ? env : DEFAULT_HEADERS_TIMEOUT_MS;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function headersTimeoutError(ms) {
|
|
75
|
+
const err = new Error(`upstream response headers timed out after ${ms}ms`);
|
|
76
|
+
// Recognized by server.js isTransient → fail fast + let the client retry, so
|
|
77
|
+
// Node's fetch pool evicts the stale connection instead of wedging.
|
|
78
|
+
err.code = 'TEAMCLAUDE_HEADERS_TIMEOUT';
|
|
79
|
+
return err;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `useProxy` is decided by the caller (it varies per attempt — e.g. direct first,
|
|
83
|
+
// then via sx after a 429). With it false, or sx unprovisioned, this is plain fetch
|
|
84
|
+
// (plus the headers-timeout guard).
|
|
85
|
+
export function upstreamFetch(url, opts = {}, sx = null, useProxy = false) {
|
|
86
|
+
const { headersTimeoutMs, ...fetchOpts } = opts;
|
|
87
|
+
const timeoutMs = resolveHeadersTimeout(headersTimeoutMs);
|
|
88
|
+
if (sx && useProxy && sx.isProvisioned()) return proxiedFetch(url, fetchOpts, sx, timeoutMs);
|
|
89
|
+
// The global-fetch escape hatch cannot speak CONNECT (that is why the tunnel
|
|
90
|
+
// is hand-rolled at all), so an upstream proxy overrides it rather than being
|
|
91
|
+
// silently dropped — on a host that needs the proxy, ignoring it means every
|
|
92
|
+
// request fails.
|
|
93
|
+
const useGlobal = USE_GLOBAL_FETCH && !proxyForHost(new URL(url).hostname);
|
|
94
|
+
return useGlobal ? directFetch(url, fetchOpts, timeoutMs) : pooledFetch(url, fetchOpts, timeoutMs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `fetch` for teamclaude's own control-plane calls — OAuth token exchange and
|
|
99
|
+
* refresh, profile, usage. Identical to global fetch when no upstream proxy is
|
|
100
|
+
* configured; tunneled through it when one is.
|
|
101
|
+
*
|
|
102
|
+
* These are not request-forwarding traffic, but they are the calls that decide
|
|
103
|
+
* whether an account can be added or kept alive at all. Leaving them direct
|
|
104
|
+
* would mean `login` fails and every token refresh dies on a host that can only
|
|
105
|
+
* reach the network through a proxy, which is precisely the reported setup.
|
|
106
|
+
*/
|
|
107
|
+
export function proxyFetch(url, opts = {}) {
|
|
108
|
+
const { headersTimeoutMs, ...rest } = opts;
|
|
109
|
+
if (!proxyForHost(new URL(url).hostname)) return fetch(url, rest);
|
|
110
|
+
return pooledFetch(url, rest, resolveHeadersTimeout(headersTimeoutMs));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Default direct path: HTTP/1.1 over a pooled keep-alive agent, so N concurrent
|
|
114
|
+
// requests use N connections instead of serializing over one h2 connection (#106).
|
|
115
|
+
//
|
|
116
|
+
// "Direct" here means "not via sx". A configured upstream proxy (config
|
|
117
|
+
// `upstreamProxy`, or HTTPS_PROXY — see upstream-proxy.js) still applies: on
|
|
118
|
+
// those hosts there is no such thing as a direct socket to api.anthropic.com,
|
|
119
|
+
// which is the whole of issue #155.
|
|
120
|
+
function pooledFetch(url, opts, timeoutMs) {
|
|
121
|
+
const u = new URL(url);
|
|
122
|
+
const isHttp = u.protocol === 'http:';
|
|
123
|
+
const port = Number(u.port) || (isHttp ? 80 : 443);
|
|
124
|
+
const proxy = proxyForHost(u.hostname);
|
|
125
|
+
if (proxy) {
|
|
126
|
+
const agent = proxyAgent(proxy, { targetHost: u.hostname, targetPort: port, tls: !isHttp, tlsOptions: opts.tlsOptions || {} });
|
|
127
|
+
return nodeRequest(u, opts, timeoutMs, { transport: isHttp ? http : https, agent });
|
|
128
|
+
}
|
|
129
|
+
return nodeRequest(u, opts, timeoutMs, { transport: isHttp ? http : https, agent: isHttp ? httpAgent : httpsAgent });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Legacy direct path (escape hatch): Node global fetch, driven by our own
|
|
133
|
+
// AbortController so we can arm a headers-only deadline and disarm it the moment
|
|
134
|
+
// headers arrive (letting the body stream with no deadline). AbortSignal.timeout
|
|
135
|
+
// can't do this — it would also kill the body.
|
|
136
|
+
function directFetch(url, opts, timeoutMs) {
|
|
137
|
+
const ctrl = new AbortController();
|
|
138
|
+
const timer = setTimeout(() => ctrl.abort(headersTimeoutError(timeoutMs)), timeoutMs);
|
|
139
|
+
timer.unref?.();
|
|
140
|
+
return fetch(url, { ...opts, signal: ctrl.signal }).then(
|
|
141
|
+
(res) => { clearTimeout(timer); return res; },
|
|
142
|
+
(err) => { clearTimeout(timer); throw err; },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// sx path: every socket is a fresh TLS connection tunneled through sx.org. The
|
|
147
|
+
// agent is created per request (its createConnection closes over this call's
|
|
148
|
+
// target), so keep-alive would give no reuse — it would only park the tunneled
|
|
149
|
+
// socket in a soon-orphaned pool and leak an open sx.org connection per request.
|
|
150
|
+
function proxiedFetch(url, opts, sx, timeoutMs) {
|
|
151
|
+
const u = new URL(url);
|
|
152
|
+
const proxy = sx.getProxy();
|
|
153
|
+
const agent = new https.Agent({ keepAlive: false });
|
|
154
|
+
agent.createConnection = (_options, cb) => {
|
|
155
|
+
// sx.tlsOptions is undefined in production (system CAs verify api.anthropic.com);
|
|
156
|
+
// tests inject a CA here to reach a self-signed upstream.
|
|
157
|
+
tunnelTls({ proxy, targetHost: u.hostname, targetPort: Number(u.port) || 443, tlsOptions: sx.tlsOptions || {} })
|
|
158
|
+
.then((sock) => cb(null, sock))
|
|
159
|
+
.catch((err) => cb(err));
|
|
160
|
+
return undefined; // socket delivered asynchronously via cb
|
|
161
|
+
};
|
|
162
|
+
return nodeRequest(u, opts, timeoutMs, { transport: https, agent });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Shared node:http(s) request → the fetch-Response subset server.js uses, with
|
|
166
|
+
// the headers-only deadline: it fires before headers arrive and tears the request
|
|
167
|
+
// down; it is cleared the instant the response starts, so a body that streams for
|
|
168
|
+
// minutes is never cut. `req` is created BEFORE the timer so a synchronous
|
|
169
|
+
// throw (e.g. an invalid client header) can't leave a scheduled timer that later
|
|
170
|
+
// fires against an uninitialized binding.
|
|
171
|
+
function nodeRequest(u, opts, timeoutMs, { transport, agent }) {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const req = transport.request(
|
|
174
|
+
u,
|
|
175
|
+
{ method: opts.method || 'GET', headers: opts.headers || {}, agent },
|
|
176
|
+
(res) => { clearTimeout(timer); cleanupAbort(); resolve(makeResponse(res)); },
|
|
177
|
+
);
|
|
178
|
+
const timer = setTimeout(() => req.destroy(headersTimeoutError(timeoutMs)), timeoutMs);
|
|
179
|
+
timer.unref?.();
|
|
180
|
+
|
|
181
|
+
// Honour an AbortSignal the way fetch does. Callers that already guard a
|
|
182
|
+
// hung call this way (oauth's refresh timeout, which otherwise wedges every
|
|
183
|
+
// request for that account) must keep working when the call is tunneled.
|
|
184
|
+
const signal = opts.signal;
|
|
185
|
+
const onAbort = () => req.destroy(signal?.reason ?? new Error('aborted'));
|
|
186
|
+
const cleanupAbort = () => signal?.removeEventListener?.('abort', onAbort);
|
|
187
|
+
if (signal) {
|
|
188
|
+
if (signal.aborted) { clearTimeout(timer); req.destroy(); reject(signal.reason ?? new Error('aborted')); return; }
|
|
189
|
+
signal.addEventListener?.('abort', onAbort, { once: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
req.once('error', (err) => { clearTimeout(timer); cleanupAbort(); reject(err); });
|
|
193
|
+
|
|
194
|
+
const body = opts.body;
|
|
195
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
196
|
+
if (body == null || method === 'GET' || method === 'HEAD') req.end();
|
|
197
|
+
else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) req.end(Buffer.from(body));
|
|
198
|
+
else req.end(String(body));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Adapt a Node IncomingMessage to a web ReadableStream. Done by hand rather than
|
|
203
|
+
// Readable.toWeb because that adapter double-closes the controller on Node 18
|
|
204
|
+
// (ERR_INVALID_STATE "Controller is already closed" when the socket's 'close'
|
|
205
|
+
// fires after 'end'), which crashes the process. The `closed` guard makes close
|
|
206
|
+
// idempotent; backpressure via pause/resume so a slow consumer doesn't buffer the
|
|
207
|
+
// whole (possibly minutes-long) stream in memory.
|
|
208
|
+
function nodeToWeb(res) {
|
|
209
|
+
let closed = false;
|
|
210
|
+
const close = (controller) => {
|
|
211
|
+
if (closed) return;
|
|
212
|
+
closed = true;
|
|
213
|
+
try { controller.close(); } catch { /* already closed / consumer gone */ }
|
|
214
|
+
};
|
|
215
|
+
return new ReadableStream({
|
|
216
|
+
start(controller) {
|
|
217
|
+
res.on('data', (chunk) => {
|
|
218
|
+
try { controller.enqueue(chunk); } catch { return; }
|
|
219
|
+
if (controller.desiredSize != null && controller.desiredSize <= 0) res.pause();
|
|
220
|
+
});
|
|
221
|
+
res.on('end', () => close(controller));
|
|
222
|
+
res.on('close', () => close(controller));
|
|
223
|
+
res.on('error', (err) => {
|
|
224
|
+
if (closed) return;
|
|
225
|
+
closed = true;
|
|
226
|
+
try { controller.error(err); } catch { /* consumer gone */ }
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
pull() { res.resume(); },
|
|
230
|
+
cancel() { res.destroy(); },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Wrap a Node IncomingMessage as the subset of a fetch Response that server.js uses.
|
|
235
|
+
function makeResponse(res) {
|
|
236
|
+
const web = nodeToWeb(res); // single web stream — one consumer either way
|
|
237
|
+
const collect = async () => {
|
|
238
|
+
const chunks = [];
|
|
239
|
+
const reader = web.getReader();
|
|
240
|
+
for (;;) {
|
|
241
|
+
const { done, value } = await reader.read();
|
|
242
|
+
if (done) break;
|
|
243
|
+
chunks.push(Buffer.from(value));
|
|
244
|
+
}
|
|
245
|
+
return Buffer.concat(chunks);
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
status: res.statusCode,
|
|
249
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
250
|
+
headers: makeHeaders(res.headers),
|
|
251
|
+
body: web,
|
|
252
|
+
async json() { return JSON.parse((await collect()).toString('utf8')); },
|
|
253
|
+
async text() { return (await collect()).toString('utf8'); },
|
|
254
|
+
async arrayBuffer() { const b = await collect(); return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// res.headers already has lowercased keys; values are string | string[] (set-cookie).
|
|
259
|
+
function makeHeaders(h) {
|
|
260
|
+
const flat = (v) => (Array.isArray(v) ? v.join(', ') : v);
|
|
261
|
+
const entries = function* () { for (const [k, v] of Object.entries(h)) yield [k, flat(v)]; };
|
|
262
|
+
return {
|
|
263
|
+
get: (name) => { const v = h[name.toLowerCase()]; return v == null ? null : flat(v); },
|
|
264
|
+
entries,
|
|
265
|
+
[Symbol.iterator]: entries,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Outbound (egress) proxy for everything teamclaude sends to Anthropic.
|
|
2
|
+
//
|
|
3
|
+
// Distinct from two other things that also say "proxy" in this codebase:
|
|
4
|
+
// - `config.proxy` is the LOCAL server Claude Code talks to (inbound).
|
|
5
|
+
// - `config.sx` is the sx.org residential-egress integration, a specific
|
|
6
|
+
// paid provider with its own provisioning API and its own routing policy
|
|
7
|
+
// (always / on-429 / off).
|
|
8
|
+
// This one is the plain corporate case: the machine cannot open a socket to
|
|
9
|
+
// api.anthropic.com at all, and every outbound connection has to go through an
|
|
10
|
+
// HTTP CONNECT proxy (issue #155). It is not a routing policy — when set, it is
|
|
11
|
+
// simply how this host reaches the internet.
|
|
12
|
+
//
|
|
13
|
+
// Node's global fetch cannot use a CONNECT proxy without undici, and "zero
|
|
14
|
+
// dependencies" is a project feature, so the tunnel is built by hand on top of
|
|
15
|
+
// the same connectThroughProxy() the sx path uses.
|
|
16
|
+
//
|
|
17
|
+
// Precedence: an explicit request to route via sx wins (sx IS an egress proxy;
|
|
18
|
+
// chaining one through the other would be two hops to solve one problem). With
|
|
19
|
+
// sx off or not selected for this attempt, the upstream proxy applies.
|
|
20
|
+
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import https from 'node:https';
|
|
23
|
+
import tls from 'node:tls';
|
|
24
|
+
import { connectThroughProxy } from './sx.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a proxy URL into the shape connectThroughProxy wants.
|
|
28
|
+
*
|
|
29
|
+
* Accepts `http://host:port`, `http://user:pass@host:port`, and a bare
|
|
30
|
+
* `host:port` (people write proxies that way constantly, and rejecting it would
|
|
31
|
+
* be pedantry). Returns null for empty input; throws on input that looks like a
|
|
32
|
+
* URL but isn't usable, so a typo in the config surfaces at startup rather than
|
|
33
|
+
* as a mystery connection failure on the first request.
|
|
34
|
+
*/
|
|
35
|
+
export function parseProxyUrl(value) {
|
|
36
|
+
if (!value || typeof value !== 'string') return null;
|
|
37
|
+
const raw = value.trim();
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
|
|
40
|
+
// A bare host:port has no scheme; give it one so URL can do the parsing.
|
|
41
|
+
const withScheme = /^[a-z0-9+.-]+:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
42
|
+
let u;
|
|
43
|
+
try {
|
|
44
|
+
u = new URL(withScheme);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`invalid proxy URL: ${value}`);
|
|
47
|
+
}
|
|
48
|
+
if (!/^https?:$/.test(u.protocol)) {
|
|
49
|
+
// socks5:// is a different wire protocol, not a CONNECT proxy — say so
|
|
50
|
+
// plainly instead of failing later inside the tunnel.
|
|
51
|
+
throw new Error(`unsupported proxy protocol "${u.protocol.replace(/:$/, '')}" (only http/https): ${value}`);
|
|
52
|
+
}
|
|
53
|
+
if (!u.hostname) throw new Error(`proxy URL has no host: ${value}`);
|
|
54
|
+
|
|
55
|
+
const port = u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 8080);
|
|
56
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
57
|
+
throw new Error(`proxy URL has an invalid port: ${value}`);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
host: u.hostname,
|
|
61
|
+
port,
|
|
62
|
+
// decodeURIComponent so a password containing e.g. %40 survives the round trip.
|
|
63
|
+
username: u.username ? decodeURIComponent(u.username) : null,
|
|
64
|
+
password: u.password ? decodeURIComponent(u.password) : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a proxy back to a storable URL, credentials intact. For writing the
|
|
70
|
+
* config — never for logs or the screen, which must use describeProxy().
|
|
71
|
+
*/
|
|
72
|
+
export function proxyToUrl(proxy) {
|
|
73
|
+
if (!proxy) return null;
|
|
74
|
+
const auth = proxy.username
|
|
75
|
+
? `${encodeURIComponent(proxy.username)}${proxy.password ? `:${encodeURIComponent(proxy.password)}` : ''}@`
|
|
76
|
+
: '';
|
|
77
|
+
return `http://${auth}${proxy.host}:${proxy.port}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Render a proxy back to a string, with the password masked. For logs and the TUI. */
|
|
81
|
+
export function describeProxy(proxy) {
|
|
82
|
+
if (!proxy) return 'none';
|
|
83
|
+
const auth = proxy.username ? `${proxy.username}:***@` : '';
|
|
84
|
+
return `http://${auth}${proxy.host}:${proxy.port}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* NO_PROXY matching, in the form everyone else implements it: a comma-separated
|
|
89
|
+
* list of suffixes, where a leading dot is optional and `*` disables the proxy
|
|
90
|
+
* entirely. Matching is on the hostname only — the port-qualified form
|
|
91
|
+
* (`host:443`) is accepted and its port ignored, which is what curl does.
|
|
92
|
+
*/
|
|
93
|
+
export function bypassesProxy(hostname, noProxy) {
|
|
94
|
+
if (!noProxy || !hostname) return false;
|
|
95
|
+
const host = hostname.toLowerCase().replace(/\.$/, '');
|
|
96
|
+
for (const raw of String(noProxy).split(',')) {
|
|
97
|
+
const entry = raw.trim().toLowerCase().replace(/:\d+$/, '').replace(/^\./, '').replace(/\.$/, '');
|
|
98
|
+
if (!entry) continue;
|
|
99
|
+
if (entry === '*') return true;
|
|
100
|
+
if (host === entry || host.endsWith(`.${entry}`)) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where the proxy setting comes from, in precedence order: the config file
|
|
107
|
+
* first (explicit and persistent), then the conventional environment variables.
|
|
108
|
+
*
|
|
109
|
+
* Honouring the environment matters for the reported case — the operator had
|
|
110
|
+
* already set HTTPS_PROXY and reasonably expected it to be used (#155). It is
|
|
111
|
+
* also what every other CLI on that machine does. `config.upstreamProxy: false`
|
|
112
|
+
* opts out entirely, for a host where the variables are set for other tools but
|
|
113
|
+
* must not apply here.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveUpstreamProxy(config = {}, env = process.env) {
|
|
116
|
+
if (config.upstreamProxy === false) return { proxy: null, source: 'disabled', noProxy: null };
|
|
117
|
+
|
|
118
|
+
const noProxy = config.noProxy ?? env.NO_PROXY ?? env.no_proxy ?? null;
|
|
119
|
+
|
|
120
|
+
if (config.upstreamProxy) {
|
|
121
|
+
return { proxy: parseProxyUrl(config.upstreamProxy), source: 'config', noProxy };
|
|
122
|
+
}
|
|
123
|
+
const candidates = [
|
|
124
|
+
['HTTPS_PROXY', env.HTTPS_PROXY], ['https_proxy', env.https_proxy],
|
|
125
|
+
['ALL_PROXY', env.ALL_PROXY], ['all_proxy', env.all_proxy],
|
|
126
|
+
];
|
|
127
|
+
for (const [name, value] of candidates) {
|
|
128
|
+
if (value) return { proxy: parseProxyUrl(value), source: `env:${name}`, noProxy };
|
|
129
|
+
}
|
|
130
|
+
return { proxy: null, source: 'none', noProxy };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Process-wide state ───────────────────────────────────────
|
|
134
|
+
//
|
|
135
|
+
// A single setting for the whole process rather than a value threaded through
|
|
136
|
+
// every call: it describes how this HOST reaches the network, so every outbound
|
|
137
|
+
// path (request forwarding, token refresh, profile and usage lookups) must agree
|
|
138
|
+
// on it. Threading it would mean passing config into oauth.js, which has no
|
|
139
|
+
// business knowing about config.
|
|
140
|
+
|
|
141
|
+
// Undefined until something resolves it. Reading it falls back to the
|
|
142
|
+
// environment alone, so short-lived commands that never load a config (and any
|
|
143
|
+
// code path that runs before startup wiring) still honour HTTPS_PROXY instead of
|
|
144
|
+
// silently going direct. A bad value in the environment must not take a command
|
|
145
|
+
// down, so a parse failure degrades to "no proxy" here and is reported loudly at
|
|
146
|
+
// startup, where the config value is validated eagerly.
|
|
147
|
+
let current = null;
|
|
148
|
+
|
|
149
|
+
export function setUpstreamProxy(resolved) {
|
|
150
|
+
current = resolved || { proxy: null, source: 'none', noProxy: null };
|
|
151
|
+
return current;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getUpstreamProxy() {
|
|
155
|
+
if (!current) {
|
|
156
|
+
try { current = resolveUpstreamProxy({}, process.env); } catch { current = { proxy: null, source: 'none', noProxy: null }; }
|
|
157
|
+
}
|
|
158
|
+
return current;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Reset the memo. Tests only. */
|
|
162
|
+
export function resetUpstreamProxy() { current = null; }
|
|
163
|
+
|
|
164
|
+
/** The proxy to use for `hostname`, or null when going direct. */
|
|
165
|
+
export function proxyForHost(hostname) {
|
|
166
|
+
const { proxy, noProxy } = getUpstreamProxy();
|
|
167
|
+
if (!proxy) return null;
|
|
168
|
+
if (bypassesProxy(hostname, noProxy)) return null;
|
|
169
|
+
return proxy;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* An http(s).Agent whose sockets are CONNECT tunnels through `proxy`.
|
|
174
|
+
*
|
|
175
|
+
* keepAlive is off: createConnection closes over one target, so a pooled socket
|
|
176
|
+
* could not be reused for a different host anyway, and parking it would leak an
|
|
177
|
+
* open proxy connection per request. The upstream path's reason for pooling
|
|
178
|
+
* (#106 — avoiding a single multiplexed h2 connection) still holds, because each
|
|
179
|
+
* tunnel is its own TCP connection carrying its own HTTP/1.1 exchange.
|
|
180
|
+
*/
|
|
181
|
+
export function proxyAgent(proxy, { targetHost, targetPort, tls: useTls = true, tlsOptions = {} }) {
|
|
182
|
+
const agent = new (useTls ? https : http).Agent({ keepAlive: false });
|
|
183
|
+
agent.createConnection = (_options, cb) => {
|
|
184
|
+
connectThroughProxy({
|
|
185
|
+
proxyHost: proxy.host,
|
|
186
|
+
proxyPort: proxy.port,
|
|
187
|
+
auth: proxy.username ? `${proxy.username}:${proxy.password ?? ''}` : null,
|
|
188
|
+
targetHost,
|
|
189
|
+
targetPort,
|
|
190
|
+
label: 'upstream proxy',
|
|
191
|
+
})
|
|
192
|
+
.then((sock) => {
|
|
193
|
+
if (!useTls) {
|
|
194
|
+
// connectThroughProxy pauses the socket so a TLS layer sees every
|
|
195
|
+
// byte. Nothing resumes it on the plaintext path, so the HTTP parser
|
|
196
|
+
// would attach to a socket that never flows and the request would sit
|
|
197
|
+
// until the headers deadline. Resume after the caller has it.
|
|
198
|
+
cb(null, sock);
|
|
199
|
+
sock.resume();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// TLS is established end-to-end over the tunnel, so the proxy sees only
|
|
203
|
+
// ciphertext and cert verification stays at its secure default.
|
|
204
|
+
const tlsSock = tls.connect({ socket: sock, servername: targetHost, ...tlsOptions });
|
|
205
|
+
const onErr = (err) => { tlsSock.removeListener('secureConnect', onOk); sock.destroy(); cb(err); };
|
|
206
|
+
const onOk = () => { tlsSock.removeListener('error', onErr); cb(null, tlsSock); };
|
|
207
|
+
tlsSock.once('secureConnect', onOk);
|
|
208
|
+
tlsSock.once('error', onErr);
|
|
209
|
+
})
|
|
210
|
+
.catch((err) => cb(err));
|
|
211
|
+
return undefined; // socket is delivered asynchronously through cb
|
|
212
|
+
};
|
|
213
|
+
return agent;
|
|
214
|
+
}
|