@rikcodes/teamclaude 1.1.20-rik.14 → 1.1.20-rik.16
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/README.md +8 -5
- package/package.json +1 -1
- package/src/account-manager.js +135 -2
- package/src/claude-env.js +60 -0
- package/src/codex-reset-credits.js +780 -0
- package/src/codex-usage.js +30 -0
- package/src/config.js +5 -0
- package/src/dashboard.js +8 -0
- package/src/index.js +86 -15
- package/src/responses-usage.js +131 -0
- package/src/server.js +310 -24
- package/src/sidecar.js +266 -3
- package/src/status-renderer.js +23 -0
- package/src/sync-accounts.js +12 -0
- package/src/tui-remote.js +5 -0
- package/src/tui.js +107 -3
package/README.md
CHANGED
|
@@ -107,10 +107,12 @@ A Claude Code session can use OpenAI models alongside the Claude accounts. They
|
|
|
107
107
|
**1. Install the sidecar and log it into your ChatGPT account** (one time):
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
|
|
110
|
+
curl -fsSL https://raw.githubusercontent.com/rikbrown/claude-code-proxy/rik/main/scripts/install.sh | bash
|
|
111
111
|
claude-code-proxy codex auth login
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
This installs [rikbrown/claude-code-proxy](https://github.com/rikbrown/claude-code-proxy), a fork of the reference sidecar. Take the fork rather than upstream's Homebrew build: it forwards Codex's quota headers, without which every bar on the account reads `unknown`, and it lets you raise the 60-second header timeout that otherwise kills a long reasoning turn. See [Timeouts](docs/openai.md#timeouts).
|
|
115
|
+
|
|
114
116
|
**2. Connect it** — add four pieces to `~/.config/teamclaude.json`:
|
|
115
117
|
|
|
116
118
|
```json
|
|
@@ -161,11 +163,11 @@ Each request is routed by the model name in its body, so one session can freely
|
|
|
161
163
|
|
|
162
164
|
1. Check that your sidecar build lists it: `curl -s http://127.0.0.1:18765/v1/models`. The sidecar has its own allow-list and rejects any id that it does not know, regardless of the TeamClaude configuration. Upgrade the sidecar if the id is missing.
|
|
163
165
|
2. Add a `customModels` row. Codex publishes the window for each model as `context_window` in `~/.codex/models_cache.json`; copy it to `contextTokens`.
|
|
164
|
-
3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary.
|
|
166
|
+
3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary. The first `SIGTERM` only starts a graceful shutdown, which a request in flight holds open; send it a second time to force the exit.
|
|
165
167
|
|
|
166
|
-
Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
|
|
168
|
+
Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers, which the fork build in step 1 does and upstream's does not — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
|
|
167
169
|
|
|
168
|
-
The sidecar appears under the account table as a `⚙` line rather than a row because it holds no subscription, is the only account its route can use, and never rotates. The line also shows its supervised process state (`up pid 98018`, or `down (code 1) 3 restarts`).
|
|
170
|
+
The sidecar appears under the account table as a `⚙` line rather than a row because it holds no subscription, is the only account its route can use, and never rotates. The line also shows its supervised process state (`up pid 98018`, or `down (code 1) 3 restarts`), followed by what the sidecar reports about itself while it is up (`2 active 3 errors`, read from its own `/monitor` endpoint; each is omitted at zero, and both are omitted if that endpoint does not answer).
|
|
169
171
|
|
|
170
172
|
#### Several ChatGPT accounts
|
|
171
173
|
|
|
@@ -221,7 +223,7 @@ Three details matter:
|
|
|
221
223
|
- **`CCP_CODEX_TRANSPORT=http` is required.** A WebSocket upgrade is relayed with the caller's own headers and draws no account, so the WebSocket transport cannot be pooled.
|
|
222
224
|
- **Do not reuse a name across providers.** Routes address accounts by name, so a shared name admits both — including the Claude account that cannot serve `gpt-*`, which outranks the sidecar on priority and wins. TeamClaude warns at startup when it sees one.
|
|
223
225
|
|
|
224
|
-
|
|
226
|
+
One thing differs from the single-account setup: each turn appears **twice** in the activity list, once per hop. Tokens and quota bars both come from the second hop, where the subscription is, so they are booked against the ChatGPT account that served. The sidecar account holds a stub login and spends nothing of its own, so it reads `N req · 0 tok` — booking it as well would double every figure.
|
|
225
227
|
|
|
226
228
|
Full details, including what happens to quota on each hop: [Several ChatGPT accounts behind one sidecar](docs/openai.md#several-chatgpt-accounts-behind-one-sidecar).
|
|
227
229
|
|
|
@@ -240,6 +242,7 @@ This feature is on by default. Each account row shows which window binds first:
|
|
|
240
242
|
| [OpenAI models](docs/openai.md) | Codex sidecar setup, custom model registration, GPT subagents, limitations |
|
|
241
243
|
| [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
|
|
242
244
|
| [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
|
|
245
|
+
| [Remote host](docs/remote.md) | Running the fleet on an always-on box: reaching it, moving the accounts, service supervision, pointing clients at it |
|
|
243
246
|
| [Compliance](docs/compliance.md) | Terms of service notes |
|
|
244
247
|
|
|
245
248
|
## Releasing this fork
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.20-rik.
|
|
3
|
+
"version": "1.1.20-rik.16",
|
|
4
4
|
"description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/account-manager.js
CHANGED
|
@@ -100,6 +100,23 @@ const PERSISTED_QUOTA_FIELDS = [
|
|
|
100
100
|
'unifiedStatus', 'unifiedStatusSeenAt',
|
|
101
101
|
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
102
102
|
'scopedWeekly',
|
|
103
|
+
// Codex free rate-limit reset credits, `{ available, applicable, seenAt }`.
|
|
104
|
+
// Worth persisting although it is not a quota: the usage probe is off by
|
|
105
|
+
// default, so without this a restart forgets that an account holds a credit
|
|
106
|
+
// until something next reads /wham/usage — and the row that says so is the
|
|
107
|
+
// only place an operator sees one at all.
|
|
108
|
+
'resetCredits',
|
|
109
|
+
// The Codex subscription tier, a string from the response headers or the
|
|
110
|
+
// usage payload. Both sources are traffic, so without this a restarted server
|
|
111
|
+
// cannot name an account's plan until it next serves a request — and a plan
|
|
112
|
+
// an account is on does not change over a restart.
|
|
113
|
+
'planType',
|
|
114
|
+
// Codex's model-scoped weekly buckets, `{ [slug]: { name, utilization,
|
|
115
|
+
// resetAt, seenAt } }`. Learned the same way and just as lossy on restart,
|
|
116
|
+
// and the per-entry `seenAt` is load-bearing beyond the reading itself: it is
|
|
117
|
+
// what orders the eviction that keeps the table under its cap, so dropping
|
|
118
|
+
// the table also drops the history that decides what makes room next.
|
|
119
|
+
'codexModelBuckets',
|
|
103
120
|
];
|
|
104
121
|
|
|
105
122
|
// The family (Fable/Sonnet) weekly buckets and the field holding when each was
|
|
@@ -127,6 +144,22 @@ function parseResetAt(value) {
|
|
|
127
144
|
return Number.isNaN(parsed) ? null : parsed;
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The quota fields a Codex account only ever LEARNS — from a `/wham/usage`
|
|
149
|
+
* payload or from the state restored off disk — so `emptyQuota` below does not
|
|
150
|
+
* seed them. Absent is meaningful for each: it says nothing has been read yet,
|
|
151
|
+
* which a seeded null would spell the same way as "read, and empty".
|
|
152
|
+
*
|
|
153
|
+
* Declared here so the few places that write them can say so (`@type` on the
|
|
154
|
+
* local that holds the quota), rather than each one reading as a property that
|
|
155
|
+
* does not exist.
|
|
156
|
+
*
|
|
157
|
+
* @typedef {object} CodexLearnedQuota
|
|
158
|
+
* @property {string} [planType] the Codex subscription tier
|
|
159
|
+
* @property {{available: number, applicable: number|null, seenAt: number}} [resetCredits] free rate-limit reset credits held, and when that was last seen
|
|
160
|
+
* @property {Record<string, {name: string, utilization: number, resetAt: number|null, seenAt: number}>} [codexModelBuckets] model-scoped weekly buckets, keyed by slug
|
|
161
|
+
*/
|
|
162
|
+
|
|
130
163
|
function emptyQuota() {
|
|
131
164
|
return {
|
|
132
165
|
// Standard API rate limits (API key accounts)
|
|
@@ -223,6 +256,17 @@ function makeAccount(acct, index) {
|
|
|
223
256
|
displayOrder: Number.isFinite(acct.displayOrder) ? acct.displayOrder : null,
|
|
224
257
|
disabled: acct.disabled || false,
|
|
225
258
|
maxUsage: acct.maxUsage ?? null,
|
|
259
|
+
// Whether this account is EXEMPT from spending one of its free Codex
|
|
260
|
+
// rate-limit reset credits (see codex-reset-credits.js). Negative-only, and
|
|
261
|
+
// the polarity is the opposite of what the name suggests: the switch that
|
|
262
|
+
// arms anything is the fleet-wide `autoRedeemResets`, because the policy it
|
|
263
|
+
// arms ("only when the whole Codex pool is dry") is a statement about the
|
|
264
|
+
// fleet. All this key can say is "never this one", so `true` and an absent
|
|
265
|
+
// key mean exactly the same thing here. Meaningless on an Anthropic
|
|
266
|
+
// account, which has no such credits — the redeemer checks the provider
|
|
267
|
+
// rather than making the field's default depend on it, so a config moved
|
|
268
|
+
// between providers keeps saying the same thing.
|
|
269
|
+
autoRedeemReset: acct.autoRedeemReset !== false,
|
|
226
270
|
upstream: acct.upstream || null,
|
|
227
271
|
modelMap: acct.modelMap || null,
|
|
228
272
|
// Fields to drop from request bodies for this account (third-party upstreams
|
|
@@ -3492,7 +3536,9 @@ export class AccountManager {
|
|
|
3492
3536
|
// threshold and every request fails, while a sibling sits at 0%.
|
|
3493
3537
|
// A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
|
|
3494
3538
|
// its own login, the forwarded numbers are its own, and they still apply.
|
|
3495
|
-
|
|
3539
|
+
// Shares its definition with the token counters, which drop a conduit hop
|
|
3540
|
+
// for the same reason: what it reports belongs to whoever served.
|
|
3541
|
+
if (!this._isCodexConduit(account)) {
|
|
3496
3542
|
for (const window of ['primary', 'secondary']) {
|
|
3497
3543
|
const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
|
|
3498
3544
|
const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
|
|
@@ -3563,12 +3609,62 @@ export class AccountManager {
|
|
|
3563
3609
|
}
|
|
3564
3610
|
}
|
|
3565
3611
|
|
|
3612
|
+
/**
|
|
3613
|
+
* Whether `account` merely RELAYS to this fleet's own Codex pool instead of
|
|
3614
|
+
* holding a subscription of its own: a translating sidecar whose back leg is
|
|
3615
|
+
* pointed back at this proxy, so each turn crosses this process twice — once
|
|
3616
|
+
* inbound on `/v1/messages` and once outbound on `/backend-api/codex/*`
|
|
3617
|
+
* (docs/openai.md, "Several ChatGPT accounts behind one sidecar").
|
|
3618
|
+
*
|
|
3619
|
+
* Keyed on three things, all of which the documented setup has and no other
|
|
3620
|
+
* account does:
|
|
3621
|
+
*
|
|
3622
|
+
* a loopback upstream the sidecar runs on this machine.
|
|
3623
|
+
* the Anthropic wire a conduit is reached on `/v1/messages`, so it
|
|
3624
|
+
* carries no `provider` field — docs/openai.md warns
|
|
3625
|
+
* that giving one `"provider": "codex"` puts it in the
|
|
3626
|
+
* foreign-subscription partition and every `gpt-*`
|
|
3627
|
+
* request then fails to find an account. A pooled
|
|
3628
|
+
* ChatGPT account is therefore never a conduit,
|
|
3629
|
+
* whatever its upstream says; it IS the pool.
|
|
3630
|
+
* a pool to relay to a standalone sidecar holding its own ChatGPT login
|
|
3631
|
+
* is NOT a conduit: everything it reports is its own,
|
|
3632
|
+
* and it is the only hop there is.
|
|
3633
|
+
*
|
|
3634
|
+
* Still imprecise in one direction, deliberately: a fleet running BOTH a
|
|
3635
|
+
* self-hosting local Anthropic backend and Codex accounts reads the former as
|
|
3636
|
+
* a conduit. Narrowing that would mean correlating the two hops of one turn,
|
|
3637
|
+
* which nothing here can do — they are separate requests sharing no id — and
|
|
3638
|
+
* the cost of the false positive is a row that under-reports rather than one
|
|
3639
|
+
* that misroutes.
|
|
3640
|
+
*
|
|
3641
|
+
* @param {any} account
|
|
3642
|
+
*/
|
|
3643
|
+
_isCodexConduit(account) {
|
|
3644
|
+
return isLocalUpstream(account)
|
|
3645
|
+
&& providerOf(account) === DEFAULT_PROVIDER
|
|
3646
|
+
&& this.accounts.some(a => providerOf(a) === 'codex');
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/**
|
|
3650
|
+
* Whether this account merely relays to our own Codex pool, for a caller
|
|
3651
|
+
* outside this class that must not treat a relayed reading as the account's
|
|
3652
|
+
* own. The private form above is the definition; this is the same question
|
|
3653
|
+
* asked from the request path, where the distinction decides whether a quota
|
|
3654
|
+
* rejection describes the account in hand or one behind it.
|
|
3655
|
+
*
|
|
3656
|
+
* @param {any} account
|
|
3657
|
+
*/
|
|
3658
|
+
isCodexConduit(account) {
|
|
3659
|
+
return this._isCodexConduit(account);
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3566
3662
|
/**
|
|
3567
3663
|
* Update cumulative token usage from response body data.
|
|
3568
3664
|
*/
|
|
3569
3665
|
updateUsage(accountIndex, inputTokens, outputTokens) {
|
|
3570
3666
|
const account = this.accounts[accountIndex];
|
|
3571
|
-
if (!account) return;
|
|
3667
|
+
if (!account || this._isCodexConduit(account)) return;
|
|
3572
3668
|
if (inputTokens) account.usage.totalInputTokens += inputTokens;
|
|
3573
3669
|
if (outputTokens) account.usage.totalOutputTokens += outputTokens;
|
|
3574
3670
|
}
|
|
@@ -3588,6 +3684,22 @@ export class AccountManager {
|
|
|
3588
3684
|
*/
|
|
3589
3685
|
recordTokenUsage(accountIndex, sessionId, model, usage) {
|
|
3590
3686
|
if (!usage) return;
|
|
3687
|
+
// A conduit hop is the SAME tokens, translated: the sidecar rebuilt this
|
|
3688
|
+
// report out of the Responses usage the pool sent it, and the pool's own hop
|
|
3689
|
+
// records the original a moment later. Both scopes would double — the two
|
|
3690
|
+
// account rows are distinct, but a session is one row and carries the same
|
|
3691
|
+
// id on both hops, so its context and spend would read twice the truth.
|
|
3692
|
+
//
|
|
3693
|
+
// Dropped rather than deduplicated because there is nothing to deduplicate
|
|
3694
|
+
// against: the two hops are separate requests that share no id, and the
|
|
3695
|
+
// conduit spends nothing of its own anyway (its login is a stub). Booking at
|
|
3696
|
+
// the hop that really spent is what keeps one turn one record.
|
|
3697
|
+
//
|
|
3698
|
+
// Only the ACCOUNT-scoped and SESSION-scoped counters stop here. Per-client
|
|
3699
|
+
// attribution still runs on the inbound hop, in the caller, because that is
|
|
3700
|
+
// the only hop that can see who asked: the outbound one comes from the
|
|
3701
|
+
// sidecar on loopback and carries no client identity at all.
|
|
3702
|
+
if (this._isCodexConduit(this.accounts[accountIndex])) return;
|
|
3591
3703
|
// The same resolver routing uses, so a token total and a routing decision
|
|
3592
3704
|
// agree about which family a request belonged to. Resolved here rather than
|
|
3593
3705
|
// at the call sites: they parse a wire format and have no business knowing
|
|
@@ -3741,6 +3853,9 @@ export class AccountManager {
|
|
|
3741
3853
|
applyCodexUsageData(accountIndex, usage) {
|
|
3742
3854
|
const account = this.accounts[accountIndex];
|
|
3743
3855
|
if (!account || !usage || usage.error) return;
|
|
3856
|
+
// The three Codex-learned fields below are written here for the first time,
|
|
3857
|
+
// so the empty-quota shape does not carry them. See CodexLearnedQuota.
|
|
3858
|
+
/** @type {typeof account.quota & CodexLearnedQuota} */
|
|
3744
3859
|
const q = account.quota;
|
|
3745
3860
|
if (usage.fiveHour) {
|
|
3746
3861
|
q.unified5h = usage.fiveHour.utilization;
|
|
@@ -3751,6 +3866,10 @@ export class AccountManager {
|
|
|
3751
3866
|
q.unified7dReset = usage.sevenDay.resetAt ?? null;
|
|
3752
3867
|
}
|
|
3753
3868
|
if (usage.planType) q.planType = safeLine(usage.planType, 64);
|
|
3869
|
+
// Stamped, because nothing else refreshes it: a payload that mentions no
|
|
3870
|
+
// credits leaves the last reading alone rather than blanking it, so the
|
|
3871
|
+
// age is the only thing that says how much the number is worth.
|
|
3872
|
+
if (usage.resetCredits) q.resetCredits = { ...usage.resetCredits, seenAt: Date.now() };
|
|
3754
3873
|
if (Array.isArray(usage.modelBuckets)) {
|
|
3755
3874
|
q.codexModelBuckets = Object.fromEntries(usage.modelBuckets.slice(0, MAX_CODEX_MODEL_BUCKETS)
|
|
3756
3875
|
.filter(bucket => bucket?.slug)
|
|
@@ -4077,6 +4196,20 @@ export class AccountManager {
|
|
|
4077
4196
|
for (const f of PERSISTED_QUOTA_FIELDS) {
|
|
4078
4197
|
if (match.quota[f] != null) account.quota[f] = match.quota[f];
|
|
4079
4198
|
}
|
|
4199
|
+
// Both writers of the Codex bucket table cap it, because its keys are
|
|
4200
|
+
// upstream header names; restoring is the one way in that never passed a
|
|
4201
|
+
// cap. A file we wrote cannot be over the ceiling, but an edited or
|
|
4202
|
+
// half-written one can, and it would then stand until some slug this
|
|
4203
|
+
// server has never seen turns up to evict the surplus. Newest readings
|
|
4204
|
+
// kept, which is the same order the eviction there works in.
|
|
4205
|
+
/** @type {typeof account.quota & CodexLearnedQuota} */
|
|
4206
|
+
const quota = account.quota;
|
|
4207
|
+
const restoredBuckets = quota.codexModelBuckets;
|
|
4208
|
+
if (restoredBuckets && Object.keys(restoredBuckets).length > MAX_CODEX_MODEL_BUCKETS) {
|
|
4209
|
+
quota.codexModelBuckets = Object.fromEntries(Object.entries(restoredBuckets)
|
|
4210
|
+
.sort((a, b) => (b[1]?.seenAt || 0) - (a[1]?.seenAt || 0))
|
|
4211
|
+
.slice(0, MAX_CODEX_MODEL_BUCKETS));
|
|
4212
|
+
}
|
|
4080
4213
|
for (const field of ['organizationType', 'rateLimitTier', 'seatTier', 'hasClaudeMax', 'hasClaudePro']) {
|
|
4081
4214
|
if (match.profile?.[field] != null) account[field] = match.profile[field];
|
|
4082
4215
|
}
|
package/src/claude-env.js
CHANGED
|
@@ -76,6 +76,66 @@ export function mergeNoProxy(...inherited) {
|
|
|
76
76
|
return out.join(',');
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** The two ways a launched client can reach the proxy. */
|
|
80
|
+
export const CLIENT_MODES = ['mitm', 'base-url'];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Which mode `run` and `env` use: a `--mitm` or `--no-mitm` flag decides, else
|
|
84
|
+
* the config's `defaultClientMode`, else MITM.
|
|
85
|
+
*
|
|
86
|
+
* MITM routes every request of the launched client through the proxy, so the
|
|
87
|
+
* hard-coded api.anthropic.com endpoints and the Codex CLI (which honours only
|
|
88
|
+
* proxy variables) are covered. It is also the whole point of the setting: with
|
|
89
|
+
* `eval "$(teamclaude env)"` the proxy variables are shell-wide, and every other
|
|
90
|
+
* tool in that shell — gh, git, a package manager — follows them to a listener
|
|
91
|
+
* that only speaks to two hosts (#382). An operator who lives in such a shell
|
|
92
|
+
* sets `defaultClientMode: "base-url"` once and opts back in per launch.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ defaultClientMode?: string }|null|undefined} config
|
|
95
|
+
* @param {string[]} flags the invocation's own arguments
|
|
96
|
+
* @returns {'mitm'|'base-url'}
|
|
97
|
+
*/
|
|
98
|
+
export function resolveClientMode(config, flags) {
|
|
99
|
+
const mitm = flags.includes('--mitm');
|
|
100
|
+
const noMitm = flags.includes('--no-mitm');
|
|
101
|
+
if (mitm && noMitm) throw new Error('choose either --mitm or --no-mitm');
|
|
102
|
+
if (mitm) return 'mitm';
|
|
103
|
+
if (noMitm) return 'base-url';
|
|
104
|
+
return config?.defaultClientMode === 'base-url' ? 'base-url' : 'mitm';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SHELL_PROXY_VARS = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {unknown} value
|
|
111
|
+
* @param {number} port
|
|
112
|
+
*/
|
|
113
|
+
function pointsAtLoopback(value, port) {
|
|
114
|
+
if (!value) return false;
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(String(value));
|
|
117
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
118
|
+
return ['127.0.0.1', 'localhost', '::1'].includes(host) && Number(url.port || 80) === port;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `unset` lines for proxy variables a previous MITM-mode eval left in the
|
|
126
|
+
* shell, so re-evaluating in base-URL mode takes the proxy back out of it. Only
|
|
127
|
+
* a value naming THIS proxy's loopback port is touched: a real corporate proxy
|
|
128
|
+
* in the same variables is the operator's and stays.
|
|
129
|
+
*
|
|
130
|
+
* @param {unknown} port
|
|
131
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
132
|
+
* @returns {string[]}
|
|
133
|
+
*/
|
|
134
|
+
export function clearSelfProxyEnvLines(port, env = process.env) {
|
|
135
|
+
const checkedPort = validPort(port);
|
|
136
|
+
return SHELL_PROXY_VARS.filter(name => pointsAtLoopback(env[name], checkedPort)).map(name => `unset ${name}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
79
139
|
// Build the shell `export` lines that point Claude Code — or any tool that
|
|
80
140
|
// spawns it, e.g. an agent multiplexer — at the proxy. This is the same
|
|
81
141
|
// environment `teamclaude run` sets up, but emitted for `eval "$(teamclaude
|