@karpeleslab/teamclaude 1.0.6 → 1.0.8
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 +176 -16
- package/package.json +7 -2
- package/src/account-manager.js +382 -24
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/identity.js +65 -0
- package/src/index.js +521 -91
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +77 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +166 -88
- package/src/sx.js +218 -0
- package/src/tui.js +231 -17
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/src/sx.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// sx.org proxy integration — an IP-based-429 workaround.
|
|
2
|
+
//
|
|
3
|
+
// teamclaude's transient 429s key on the proxy's OUTBOUND IP, not the account,
|
|
4
|
+
// so account failover doesn't help. sx.org is a residential proxy-port provider:
|
|
5
|
+
// with an API key we provision a port and tunnel upstream Anthropic traffic
|
|
6
|
+
// through it, giving a different egress IP. Crucially, TLS terminates END-TO-END
|
|
7
|
+
// at the upstream (we `tls.connect` over the tunnel with the upstream's
|
|
8
|
+
// servername and the default secure cert check) — the sx.org proxy only ever
|
|
9
|
+
// relays ciphertext and cannot see request content.
|
|
10
|
+
//
|
|
11
|
+
// When no API key is configured (or mode is 'off') this module is dormant and the
|
|
12
|
+
// dial paths behave exactly as before — routing is decided per-attempt by
|
|
13
|
+
// useByDefault() / useOn429() / useForConnect(), all false until provisioned.
|
|
14
|
+
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
import tls from 'node:tls';
|
|
17
|
+
|
|
18
|
+
const CONNECT_TIMEOUT_MS = 30000; // residential exits can be slow to establish
|
|
19
|
+
|
|
20
|
+
// Resolved per call (not at import) so tests can point it at a local mock.
|
|
21
|
+
const sxBase = () => process.env.SX_API_BASE || 'https://api.sx.org';
|
|
22
|
+
|
|
23
|
+
// ── sx.org REST (apiKey is a query param; these hit api.sx.org directly, never
|
|
24
|
+
// the proxy, and are unrelated to Anthropic traffic) ──
|
|
25
|
+
async function sxGet(path, apiKey, params = {}) {
|
|
26
|
+
const url = new URL(sxBase() + path);
|
|
27
|
+
url.searchParams.set('apiKey', apiKey);
|
|
28
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
|
29
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sxPost(path, apiKey, body) {
|
|
34
|
+
const url = new URL(sxBase() + path);
|
|
35
|
+
url.searchParams.set('apiKey', apiKey);
|
|
36
|
+
const res = await fetch(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SX_MODES = ['off', '429', 'always'];
|
|
45
|
+
const normalizeMode = (m) => (SX_MODES.includes(m) ? m : 'always');
|
|
46
|
+
|
|
47
|
+
// Normalize either API shape into { host, port, username, password, portId }.
|
|
48
|
+
// ports-list: { proxy: "host:port", login, password, id }
|
|
49
|
+
// create-port: { server, port, login, password, id }
|
|
50
|
+
function parsePort(p) {
|
|
51
|
+
let host, port;
|
|
52
|
+
if (typeof p.proxy === 'string' && p.proxy.includes(':')) {
|
|
53
|
+
const i = p.proxy.lastIndexOf(':');
|
|
54
|
+
host = p.proxy.slice(0, i); port = p.proxy.slice(i + 1);
|
|
55
|
+
} else {
|
|
56
|
+
host = p.server; port = p.port;
|
|
57
|
+
}
|
|
58
|
+
return { host, port: parseInt(port, 10), username: p.login, password: p.password, portId: p.id };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Open a CONNECT tunnel through an HTTP proxy to targetHost:targetPort and
|
|
63
|
+
* resolve with the raw (still-plaintext) socket once the proxy answers 200.
|
|
64
|
+
*/
|
|
65
|
+
export function connectThroughProxy({ proxyHost, proxyPort, auth, targetHost, targetPort, timeout = CONNECT_TIMEOUT_MS }) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
// autoSelectFamily (happy-eyeballs) — default on Node 20+ but not 18; set it
|
|
68
|
+
// so a dual-stack proxy host whose IPv6 path is unreachable falls back to IPv4
|
|
69
|
+
// instead of hanging the connect (sx.org returns an IP, but be robust).
|
|
70
|
+
const sock = net.connect({ port: proxyPort, host: proxyHost, autoSelectFamily: true });
|
|
71
|
+
let buf = '';
|
|
72
|
+
const timer = setTimeout(() => fail(new Error(`sx.org proxy CONNECT timed out after ${timeout}ms`)), timeout);
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
sock.removeListener('data', onData);
|
|
76
|
+
sock.removeListener('error', fail);
|
|
77
|
+
};
|
|
78
|
+
const fail = (err) => { cleanup(); sock.destroy(); reject(err); };
|
|
79
|
+
const onData = (chunk) => {
|
|
80
|
+
buf += chunk.toString('latin1');
|
|
81
|
+
const idx = buf.indexOf('\r\n\r\n');
|
|
82
|
+
if (idx < 0) { if (buf.length > 65536) fail(new Error('sx.org proxy CONNECT response too large')); return; }
|
|
83
|
+
const statusLine = buf.slice(0, buf.indexOf('\r\n'));
|
|
84
|
+
const m = statusLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
|
|
85
|
+
if (!m || m[1] !== '200') { fail(new Error(`sx.org proxy refused CONNECT: ${statusLine}`)); return; }
|
|
86
|
+
cleanup();
|
|
87
|
+
sock.pause(); // stop flowing so the TLS layer we hand it to sees every byte
|
|
88
|
+
const rest = Buffer.from(buf.slice(idx + 4), 'latin1'); // bytes already past the header
|
|
89
|
+
if (rest.length) sock.unshift(rest);
|
|
90
|
+
resolve(sock);
|
|
91
|
+
};
|
|
92
|
+
sock.once('connect', () => {
|
|
93
|
+
const lines = [`CONNECT ${targetHost}:${targetPort} HTTP/1.1`, `Host: ${targetHost}:${targetPort}`];
|
|
94
|
+
if (auth) lines.push(`Proxy-Authorization: Basic ${Buffer.from(auth).toString('base64')}`);
|
|
95
|
+
lines.push('Proxy-Connection: keep-alive', '', '');
|
|
96
|
+
sock.write(lines.join('\r\n'));
|
|
97
|
+
});
|
|
98
|
+
sock.on('data', onData);
|
|
99
|
+
sock.once('error', fail);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* CONNECT through `proxy`, then complete a TLS handshake to targetHost so TLS is
|
|
105
|
+
* end-to-end (the proxy sees ciphertext only). Resolves with the TLSSocket after
|
|
106
|
+
* secureConnect. Cert verification stays at its secure default; tests inject a CA
|
|
107
|
+
* via tlsOptions.ca.
|
|
108
|
+
*/
|
|
109
|
+
export async function tunnelTls({ proxy, targetHost, targetPort = 443, tlsOptions = {} }) {
|
|
110
|
+
const sock = await connectThroughProxy({
|
|
111
|
+
proxyHost: proxy.host,
|
|
112
|
+
proxyPort: proxy.port,
|
|
113
|
+
auth: proxy.username ? `${proxy.username}:${proxy.password}` : null,
|
|
114
|
+
targetHost,
|
|
115
|
+
targetPort,
|
|
116
|
+
});
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const tlsSock = tls.connect({ socket: sock, servername: targetHost, ...tlsOptions });
|
|
119
|
+
const onErr = (err) => { tlsSock.removeListener('secureConnect', onOk); sock.destroy(); reject(err); };
|
|
120
|
+
const onOk = () => { tlsSock.removeListener('error', onErr); resolve(tlsSock); };
|
|
121
|
+
tlsSock.once('secureConnect', onOk);
|
|
122
|
+
tlsSock.once('error', onErr);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Holds the sx.org credential + the provisioned proxy. Shared in-process by the
|
|
128
|
+
* reverse proxy, the MITM handler, and the TUI so a key change applies live.
|
|
129
|
+
*/
|
|
130
|
+
export class SxManager {
|
|
131
|
+
constructor({ log = () => {} } = {}) {
|
|
132
|
+
this.log = log;
|
|
133
|
+
this.apiKey = null;
|
|
134
|
+
this.proxy = null; // { host, port, username, password, portId }
|
|
135
|
+
this.mode = 'always'; // off | 429 | always — how routing decisions are made
|
|
136
|
+
this._rlUntil = 0; // sticky-routing window end (ms) for '429' mode
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
isProvisioned() { return !!(this.apiKey && this.proxy); }
|
|
140
|
+
getProxy() { return this.proxy; }
|
|
141
|
+
getMode() { return this.mode; }
|
|
142
|
+
|
|
143
|
+
// ── routing decisions ──
|
|
144
|
+
// Reverse-proxy first attempt: only 'always' routes pre-emptively.
|
|
145
|
+
useByDefault() { return this.isProvisioned() && this.mode === 'always'; }
|
|
146
|
+
// Reverse-proxy retry after a 429: 'always' and '429' both route (the 429 is
|
|
147
|
+
// IP-based, so a fresh egress IP can clear it).
|
|
148
|
+
useOn429() { return this.isProvisioned() && this.mode !== 'off'; }
|
|
149
|
+
// MITM connect-time (one tunnel carries many requests, so no per-request
|
|
150
|
+
// failover): 'always' routes; '429' routes only inside the sticky window set
|
|
151
|
+
// when a 429 was recently observed.
|
|
152
|
+
useForConnect() {
|
|
153
|
+
if (!this.isProvisioned() || this.mode === 'off') return false;
|
|
154
|
+
return this.mode === 'always' || this.isRecentlyRateLimited();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
noteRateLimited(seconds = 60) { this._rlUntil = Date.now() + Math.min(Math.max(seconds, 1), 300) * 1000; }
|
|
158
|
+
isRecentlyRateLimited() { return Date.now() < this._rlUntil; }
|
|
159
|
+
|
|
160
|
+
/** Set the API key (+ optional mode) and provision unless mode is 'off'. */
|
|
161
|
+
async configure(apiKey, mode = this.mode) {
|
|
162
|
+
this.mode = normalizeMode(mode);
|
|
163
|
+
if (!apiKey) { this.disable(); return { ok: false, error: 'no API key' }; }
|
|
164
|
+
this.apiKey = apiKey;
|
|
165
|
+
if (this.mode === 'off') { this.proxy = null; return { ok: true, mode: this.mode, proxy: null }; }
|
|
166
|
+
return this._ensureProxy();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Switch mode WITHOUT clearing the key; provision lazily when turning on. */
|
|
170
|
+
async setMode(mode) {
|
|
171
|
+
this.mode = normalizeMode(mode);
|
|
172
|
+
if (this.mode === 'off') { this.proxy = null; return { ok: true, mode: this.mode }; }
|
|
173
|
+
if (this.apiKey && !this.proxy) return this._ensureProxy();
|
|
174
|
+
return { ok: true, mode: this.mode, proxy: this.proxy };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Full deconfigure — forget the key entirely. */
|
|
178
|
+
disable() { this.apiKey = null; this.proxy = null; }
|
|
179
|
+
|
|
180
|
+
async _ensureProxy() {
|
|
181
|
+
try {
|
|
182
|
+
this.proxy = await this.provision();
|
|
183
|
+
this.log(`[TeamClaude] sx.org proxy ready: ${this.proxy.host}:${this.proxy.port}`);
|
|
184
|
+
return { ok: true, mode: this.mode, proxy: this.proxy };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.proxy = null;
|
|
187
|
+
this.log(`[TeamClaude] sx.org provisioning failed: ${err.message}`);
|
|
188
|
+
return { ok: false, error: err.message };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Account balance/traffic, or null on error. */
|
|
193
|
+
async getBalance() {
|
|
194
|
+
if (!this.apiKey) return null;
|
|
195
|
+
try {
|
|
196
|
+
const r = await sxGet('/v2/user/balance', this.apiKey);
|
|
197
|
+
return r?.success ? r : null;
|
|
198
|
+
} catch { return null; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Reuse an active port if one exists, else create a residential US one. */
|
|
202
|
+
async provision() {
|
|
203
|
+
if (!this.apiKey) throw new Error('sx.org API key not set');
|
|
204
|
+
const list = await sxGet('/v2/proxy/ports', this.apiKey, { per_page: 50 });
|
|
205
|
+
const proxies = list?.message?.proxies || [];
|
|
206
|
+
const active = proxies.find((p) => p.status === 1 && p.login && p.password && p.proxy);
|
|
207
|
+
if (active) return parsePort(active);
|
|
208
|
+
|
|
209
|
+
const created = await sxPost('/v2/proxy/create-port', this.apiKey, {
|
|
210
|
+
country_code: 'US', proxy_type_id: 1, type_id: 1, // type_id 1 = residential
|
|
211
|
+
});
|
|
212
|
+
if (!created?.success || !created.data) {
|
|
213
|
+
const detail = created?.errors ? JSON.stringify(created.errors) : (created?.message || JSON.stringify(created));
|
|
214
|
+
throw new Error(`sx.org create-port failed: ${detail}`);
|
|
215
|
+
}
|
|
216
|
+
return parsePort(created.data);
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { importCredentials, fetchProfile } from './oauth.js';
|
|
2
|
+
import { sameIdentity } from './identity.js';
|
|
2
3
|
|
|
3
4
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
4
5
|
|
|
@@ -114,16 +115,18 @@ function timestamp() {
|
|
|
114
115
|
// ── TUI class ────────────────────────────────────────────────
|
|
115
116
|
|
|
116
117
|
export class TUI {
|
|
117
|
-
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit }) {
|
|
118
|
+
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null }) {
|
|
118
119
|
this.am = accountManager;
|
|
119
120
|
this.config = config;
|
|
120
121
|
this.saveConfig = saveConfig;
|
|
121
122
|
this.syncAccounts = syncAccounts;
|
|
122
123
|
this.onQuit = onQuit;
|
|
124
|
+
this.sx = sx; // sx.org proxy manager (may be null)
|
|
125
|
+
this.sxBalance = null; // last fetched sx.org balance, for the settings screen
|
|
123
126
|
|
|
124
127
|
this.log = []; // completed activity entries
|
|
125
128
|
this.active = new Map(); // in-flight requests
|
|
126
|
-
this.mode = 'normal'; // normal | select | add | input
|
|
129
|
+
this.mode = 'normal'; // normal | select | add | input | settings
|
|
127
130
|
this.selAction = null; // switch | remove
|
|
128
131
|
this.selIdx = 0;
|
|
129
132
|
this.inputPrompt = '';
|
|
@@ -220,6 +223,7 @@ export class TUI {
|
|
|
220
223
|
case 'select': this._keySelect(k); break;
|
|
221
224
|
case 'add': this._keyAdd(k); break;
|
|
222
225
|
case 'input': this._keyInput(k); break;
|
|
226
|
+
case 'settings': this._keySettings(k); break;
|
|
223
227
|
}
|
|
224
228
|
this.render();
|
|
225
229
|
}
|
|
@@ -232,8 +236,68 @@ export class TUI {
|
|
|
232
236
|
else if (k === 'r' && this.am.accounts.length > 0) {
|
|
233
237
|
this.mode = 'select'; this.selAction = 'remove'; this.selIdx = 0;
|
|
234
238
|
}
|
|
239
|
+
else if (k === 'd' && this.am.accounts.length > 0) {
|
|
240
|
+
this.mode = 'select'; this.selAction = 'toggle'; this.selIdx = this.am.currentIndex;
|
|
241
|
+
}
|
|
235
242
|
else if (k === 'a') { this.mode = 'add'; }
|
|
236
243
|
else if (k === 'R') { this._doSync(); }
|
|
244
|
+
else if (k === 'g' && this.sx) { this.mode = 'settings'; this._loadSxBalance(); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_keySettings(k) {
|
|
248
|
+
if (k === 't') {
|
|
249
|
+
this.mode = 'input';
|
|
250
|
+
this.inputPrompt = 'Switch threshold % (1-100)';
|
|
251
|
+
this.inputBuf = '';
|
|
252
|
+
this.inputCb = v => { if (v) this._doSetThreshold(v.trim()); };
|
|
253
|
+
}
|
|
254
|
+
else if (k === 'p') {
|
|
255
|
+
this.mode = 'input';
|
|
256
|
+
this.inputPrompt = 'Quota probe seconds (0=off, min 30)';
|
|
257
|
+
this.inputBuf = '';
|
|
258
|
+
this.inputCb = v => { if (v) this._doSetProbe(v.trim()); };
|
|
259
|
+
}
|
|
260
|
+
else if (k === 'k') {
|
|
261
|
+
this.mode = 'input';
|
|
262
|
+
this.inputPrompt = 'sx.org API key';
|
|
263
|
+
this.inputBuf = '';
|
|
264
|
+
this.inputCb = v => { if (v) this._doSetSxKey(v.trim()); };
|
|
265
|
+
}
|
|
266
|
+
else if (k === 'm') { this._doCycleSxMode(); }
|
|
267
|
+
else if (k === 'x') { this._doClearSxKey(); }
|
|
268
|
+
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async _doSetThreshold(input) {
|
|
272
|
+
const pct = Number(input);
|
|
273
|
+
if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
|
|
274
|
+
this._addLog('Invalid threshold — enter 1–100'); this.mode = 'settings'; if (this.running) this.render(); return;
|
|
275
|
+
}
|
|
276
|
+
const v = Math.round(pct) / 100;
|
|
277
|
+
this.config.switchThreshold = v;
|
|
278
|
+
this.am.switchThreshold = v; // apply to the running rotation immediately
|
|
279
|
+
try { await this.saveConfig(this.config); }
|
|
280
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
281
|
+
this._addLog(`Switch threshold set to ${Math.round(v * 100)}%`);
|
|
282
|
+
this.mode = 'settings';
|
|
283
|
+
if (this.running) this.render();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async _doSetProbe(input) {
|
|
287
|
+
let secs = parseInt(input, 10);
|
|
288
|
+
if (Number.isNaN(secs) || secs < 0) {
|
|
289
|
+
this._addLog('Invalid interval — enter 0 (off) or seconds'); this.mode = 'settings'; if (this.running) this.render(); return;
|
|
290
|
+
}
|
|
291
|
+
if (secs > 0 && secs < 30) secs = 30; // match the CLI minimum (don't hammer the usage endpoint)
|
|
292
|
+
this.config.quotaProbeSeconds = secs;
|
|
293
|
+
try { await this.saveConfig(this.config); }
|
|
294
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
295
|
+
// syncAccounts re-reads disk config and reschedules the running prober live.
|
|
296
|
+
try { await this.syncAccounts(); }
|
|
297
|
+
catch (e) { this._addLog(`Reload failed: ${e.message}`); }
|
|
298
|
+
this._addLog(secs > 0 ? `Quota probe every ${secs}s` : 'Quota probe disabled');
|
|
299
|
+
this.mode = 'settings';
|
|
300
|
+
if (this.running) this.render();
|
|
237
301
|
}
|
|
238
302
|
|
|
239
303
|
_keySelect(k) {
|
|
@@ -244,6 +308,8 @@ export class TUI {
|
|
|
244
308
|
if (this.selAction === 'switch') {
|
|
245
309
|
this.am.currentIndex = this.selIdx;
|
|
246
310
|
this._addLog(`Switched to "${this.am.accounts[this.selIdx].name}"`);
|
|
311
|
+
} else if (this.selAction === 'toggle') {
|
|
312
|
+
this._doToggleDisabled(this.selIdx);
|
|
247
313
|
} else {
|
|
248
314
|
this._doRemove(this.selIdx);
|
|
249
315
|
}
|
|
@@ -290,6 +356,57 @@ export class TUI {
|
|
|
290
356
|
}
|
|
291
357
|
}
|
|
292
358
|
|
|
359
|
+
// ── sx.org settings ────────────────────────────────
|
|
360
|
+
|
|
361
|
+
_loadSxBalance() {
|
|
362
|
+
this.sxBalance = null;
|
|
363
|
+
if (!this.sx?.apiKey) return;
|
|
364
|
+
this.sx.getBalance()
|
|
365
|
+
.then(b => { this.sxBalance = b; if (this.running) this.render(); })
|
|
366
|
+
.catch(() => {});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_sxModeLabel(m) { return m === 'always' ? 'always' : m === '429' ? 'on 429 only' : 'off'; }
|
|
370
|
+
|
|
371
|
+
async _doSetSxKey(key) {
|
|
372
|
+
const mode = this.config.sx?.mode || 'always';
|
|
373
|
+
this.config.sx = { apiKey: key, mode };
|
|
374
|
+
try { await this.saveConfig(this.config); }
|
|
375
|
+
catch (e) { this._addLog(`Failed to save sx.org key: ${e.message}`); }
|
|
376
|
+
this._addLog('sx.org: configuring...');
|
|
377
|
+
const r = await this.sx.configure(key, mode);
|
|
378
|
+
if (r.ok && r.proxy) this._addLog(`sx.org key saved — proxy ${r.proxy.host}:${r.proxy.port} (mode: ${this._sxModeLabel(mode)})`);
|
|
379
|
+
else if (r.ok) this._addLog(`sx.org key saved (mode: ${this._sxModeLabel(mode)})`);
|
|
380
|
+
else this._addLog(`sx.org error: ${r.error}`);
|
|
381
|
+
this._loadSxBalance();
|
|
382
|
+
this.mode = 'settings';
|
|
383
|
+
if (this.running) this.render();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Cycle off → on-429 → always. Keeps the API key, so the user can disable
|
|
387
|
+
// sx.org without deconfiguring it.
|
|
388
|
+
async _doCycleSxMode() {
|
|
389
|
+
const order = ['off', '429', 'always'];
|
|
390
|
+
const next = order[(order.indexOf(this.sx.getMode()) + 1) % order.length];
|
|
391
|
+
this.config.sx = { ...(this.config.sx || {}), mode: next };
|
|
392
|
+
try { await this.saveConfig(this.config); }
|
|
393
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
394
|
+
const r = await this.sx.setMode(next);
|
|
395
|
+
this._addLog(`sx.org mode: ${this._sxModeLabel(next)}${r.ok ? '' : ` — ${r.error}`}`);
|
|
396
|
+
if (next !== 'off') this._loadSxBalance();
|
|
397
|
+
if (this.running) this.render();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async _doClearSxKey() {
|
|
401
|
+
this.config.sx = null;
|
|
402
|
+
try { await this.saveConfig(this.config); }
|
|
403
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
404
|
+
this.sx.disable();
|
|
405
|
+
this.sxBalance = null;
|
|
406
|
+
this._addLog('sx.org key cleared');
|
|
407
|
+
if (this.running) this.render();
|
|
408
|
+
}
|
|
409
|
+
|
|
293
410
|
async _doImport() {
|
|
294
411
|
try {
|
|
295
412
|
this._addLog('Importing credentials...');
|
|
@@ -314,34 +431,50 @@ export class TUI {
|
|
|
314
431
|
const entry = {
|
|
315
432
|
name, type: 'oauth', source: 'import',
|
|
316
433
|
accountUuid: profile?.accountUuid || null,
|
|
434
|
+
orgUuid: profile?.orgUuid || null,
|
|
435
|
+
orgName: profile?.orgName || null,
|
|
317
436
|
accessToken: creds.accessToken,
|
|
318
437
|
refreshToken: creds.refreshToken,
|
|
319
438
|
expiresAt: creds.expiresAt,
|
|
320
439
|
};
|
|
321
440
|
|
|
322
|
-
// Deduplicate
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
: -1;
|
|
441
|
+
// Deduplicate by account+org identity (same email in a different org is a
|
|
442
|
+
// distinct account), then by name.
|
|
443
|
+
let idx = this.config.accounts.findIndex(a => sameIdentity(a, entry));
|
|
326
444
|
if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
|
|
327
445
|
|
|
328
446
|
if (idx >= 0) {
|
|
329
|
-
this.config.accounts[idx]
|
|
447
|
+
const prev = this.config.accounts[idx];
|
|
448
|
+
this.config.accounts[idx] = { ...prev, ...entry, name: prev.name };
|
|
330
449
|
// Update the running account manager entry
|
|
331
|
-
const amAcct = this.am.accounts[idx];
|
|
450
|
+
const amAcct = this.am.accounts.find(a => sameIdentity(a, entry)) || this.am.accounts[idx];
|
|
332
451
|
if (amAcct) {
|
|
333
452
|
amAcct.credential = creds.accessToken;
|
|
334
453
|
amAcct.refreshToken = creds.refreshToken;
|
|
335
454
|
amAcct.expiresAt = creds.expiresAt;
|
|
336
455
|
amAcct.accountUuid = entry.accountUuid;
|
|
337
|
-
amAcct.
|
|
456
|
+
amAcct.orgUuid = entry.orgUuid;
|
|
457
|
+
amAcct.orgName = entry.orgName;
|
|
338
458
|
if (amAcct.status === 'error') amAcct.status = 'active';
|
|
339
459
|
}
|
|
340
|
-
this._addLog(`Updated account "${name}"`);
|
|
460
|
+
this._addLog(`Updated account "${prev.name}"`);
|
|
341
461
|
} else {
|
|
462
|
+
// New org for this person: disambiguate colliding email names with " (org)".
|
|
463
|
+
if (profile?.accountUuid) {
|
|
464
|
+
const orgLbl = a => a.orgName || (a.orgUuid ? a.orgUuid.slice(0, 8) : 'org');
|
|
465
|
+
const collisions = this.config.accounts.filter(
|
|
466
|
+
a => a.accountUuid === entry.accountUuid && !sameIdentity(a, entry)
|
|
467
|
+
);
|
|
468
|
+
if (collisions.length > 0) {
|
|
469
|
+
for (const c of collisions) {
|
|
470
|
+
if (!c.name.includes(' (')) c.name = `${c.name} (${orgLbl(c)})`;
|
|
471
|
+
}
|
|
472
|
+
entry.name = `${name} (${orgLbl(entry)})`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
342
475
|
this.config.accounts.push(entry);
|
|
343
476
|
this.am.addAccount(entry);
|
|
344
|
-
this._addLog(`Imported account "${name}"`);
|
|
477
|
+
this._addLog(`Imported account "${entry.name}"`);
|
|
345
478
|
}
|
|
346
479
|
|
|
347
480
|
await this.saveConfig(this.config);
|
|
@@ -369,10 +502,37 @@ export class TUI {
|
|
|
369
502
|
this._addLog(`Removed account "${name}"`);
|
|
370
503
|
}
|
|
371
504
|
|
|
505
|
+
async _doToggleDisabled(idx) {
|
|
506
|
+
if (idx < 0 || idx >= this.am.accounts.length) return;
|
|
507
|
+
const acct = this.am.accounts[idx];
|
|
508
|
+
const next = !acct.disabled;
|
|
509
|
+
this.am.setDisabled(idx, next); // re-enabling also clears a stuck error state
|
|
510
|
+
// Write an explicit boolean (not delete): saveConfig merges over the on-disk
|
|
511
|
+
// entry, so a `delete` would leave a stale `disabled: true` from disk intact.
|
|
512
|
+
if (this.config.accounts[idx]) this.config.accounts[idx].disabled = next;
|
|
513
|
+
await this.saveConfig(this.config);
|
|
514
|
+
this._addLog(`${next ? 'Disabled' : 'Enabled'} account "${acct.name}"`);
|
|
515
|
+
}
|
|
516
|
+
|
|
372
517
|
// ── rendering ──────────────────────────────────────
|
|
373
518
|
|
|
374
519
|
render() {
|
|
375
520
|
if (!this.running) return;
|
|
521
|
+
// Guard against re-entry: clearing an expired quota logs, and _addLog calls
|
|
522
|
+
// render() again — without this the nested call would render twice.
|
|
523
|
+
if (this._rendering) return;
|
|
524
|
+
this._rendering = true;
|
|
525
|
+
try {
|
|
526
|
+
this._render();
|
|
527
|
+
} finally {
|
|
528
|
+
this._rendering = false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
_render() {
|
|
533
|
+
// Reset the display the instant a quota window (e.g. 5-hour session) expires,
|
|
534
|
+
// instead of waiting for the next request to clear it.
|
|
535
|
+
this.am.refreshExpiredQuotas();
|
|
376
536
|
const W = process.stdout.columns || 80;
|
|
377
537
|
const H = process.stdout.rows || 24;
|
|
378
538
|
|
|
@@ -390,6 +550,10 @@ export class TUI {
|
|
|
390
550
|
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
391
551
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
392
552
|
|
|
553
|
+
const footerH = 2;
|
|
554
|
+
if (this.mode === 'settings') {
|
|
555
|
+
this._renderSettings(lines);
|
|
556
|
+
} else {
|
|
393
557
|
// ── Accounts
|
|
394
558
|
if (this.am.accounts.length === 0) {
|
|
395
559
|
lines.push('');
|
|
@@ -423,11 +587,11 @@ export class TUI {
|
|
|
423
587
|
}
|
|
424
588
|
|
|
425
589
|
// Completed log
|
|
426
|
-
const footerH = 2;
|
|
427
590
|
const space = Math.max(0, H - lines.length - footerH);
|
|
428
591
|
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
429
592
|
lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
|
|
430
593
|
}
|
|
594
|
+
} // end non-settings body
|
|
431
595
|
|
|
432
596
|
// Pad to fill
|
|
433
597
|
while (lines.length < H - footerH) lines.push('');
|
|
@@ -463,9 +627,11 @@ export class TUI {
|
|
|
463
627
|
// Type
|
|
464
628
|
const type = gray(a.type.padEnd(7));
|
|
465
629
|
|
|
466
|
-
// Status
|
|
630
|
+
// Status — a disabled account is shown as such regardless of its quota state.
|
|
467
631
|
let status;
|
|
468
|
-
|
|
632
|
+
if (a.disabled) {
|
|
633
|
+
status = gray('disabled');
|
|
634
|
+
} else switch (a.status) {
|
|
469
635
|
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
470
636
|
case 'throttled': status = yellow('throttled'); break;
|
|
471
637
|
case 'exhausted': status = red('exhausted'); break;
|
|
@@ -478,7 +644,7 @@ export class TUI {
|
|
|
478
644
|
const q = a.quota;
|
|
479
645
|
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
|
|
480
646
|
|
|
481
|
-
if (q.unified5h != null || q.unified7d != null) {
|
|
647
|
+
if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
|
|
482
648
|
r1 = q.unified5h;
|
|
483
649
|
r2 = q.unified7d;
|
|
484
650
|
t1 = q.unified5hReset;
|
|
@@ -497,16 +663,64 @@ export class TUI {
|
|
|
497
663
|
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
|
|
498
664
|
if (showBoth) {
|
|
499
665
|
line += ` ${l2} ${bar(r2, bw, t2)}`;
|
|
666
|
+
// Sonnet weekly bar — only shown when the usage probe has populated it.
|
|
667
|
+
if (q.unified7dSonnet != null) {
|
|
668
|
+
line += ` S7 ${bar(q.unified7dSonnet, bw, q.unified7dSonnetReset)}`;
|
|
669
|
+
}
|
|
500
670
|
}
|
|
501
671
|
return line;
|
|
502
672
|
}
|
|
503
673
|
|
|
674
|
+
_renderSettings(lines) {
|
|
675
|
+
lines.push('');
|
|
676
|
+
// ── Rotation
|
|
677
|
+
const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
|
|
678
|
+
lines.push(bold(' Rotation') + dim(' — switch accounts when quota crosses the threshold'));
|
|
679
|
+
lines.push(` Switch at: ${green(`${Math.round(thr * 100)}%`)} ${dim('utilization')}`);
|
|
680
|
+
lines.push('');
|
|
681
|
+
// ── Quota probe
|
|
682
|
+
const probe = this.config.quotaProbeSeconds || 0;
|
|
683
|
+
lines.push(bold(' Quota probe') + dim(' — refresh idle accounts from the usage endpoint'));
|
|
684
|
+
lines.push(` Interval: ${probe > 0 ? green(`${probe}s`) : gray('off (passive)')}`);
|
|
685
|
+
lines.push('');
|
|
686
|
+
// ── sx.org
|
|
687
|
+
lines.push(bold(' sx.org proxy') + dim(' — route upstream via a residential IP (429 workaround)'));
|
|
688
|
+
lines.push('');
|
|
689
|
+
if (!this.sx) { lines.push(yellow(' Unavailable in this build.')); return; }
|
|
690
|
+
const key = this.config.sx?.apiKey;
|
|
691
|
+
const masked = key ? key.slice(0, 4) + '…' + key.slice(-4) : dim('(not set)');
|
|
692
|
+
const mode = this.sx.getMode();
|
|
693
|
+
const modeStr = mode === 'always' ? green('always')
|
|
694
|
+
: mode === '429' ? cyan('on 429 only')
|
|
695
|
+
: gray('off');
|
|
696
|
+
const p = this.sx.getProxy?.();
|
|
697
|
+
const proxyStr = mode === 'off' ? gray('—')
|
|
698
|
+
: this.sx.isProvisioned() ? green(`${p.host}:${p.port}`)
|
|
699
|
+
: key ? yellow('not provisioned')
|
|
700
|
+
: gray('no key');
|
|
701
|
+
const b = this.sxBalance;
|
|
702
|
+
lines.push(` Mode: ${modeStr}`);
|
|
703
|
+
lines.push(` API key: ${masked}`);
|
|
704
|
+
lines.push(` Proxy: ${proxyStr}`);
|
|
705
|
+
lines.push(` Balance: ${b ? green('$' + Number(b.balance).toFixed(4)) : dim('…')}`);
|
|
706
|
+
lines.push('');
|
|
707
|
+
lines.push(dim(' always tunnel ALL upstream traffic through sx.org'));
|
|
708
|
+
lines.push(dim(' on 429 only retry through sx.org after a 429 (fresh IP)'));
|
|
709
|
+
lines.push(dim(' off never use sx.org (API key is kept)'));
|
|
710
|
+
lines.push('');
|
|
711
|
+
lines.push(dim(' TLS stays end-to-end; residential traffic is metered by sx.org.'));
|
|
712
|
+
}
|
|
713
|
+
|
|
504
714
|
_renderFooter() {
|
|
505
715
|
switch (this.mode) {
|
|
506
716
|
case 'normal':
|
|
507
|
-
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('R')}eload ${bold('q')}uit`;
|
|
717
|
+
return ` ${bold('s')}witch ${bold('a')}dd ${bold('r')}emove ${bold('d')}isable ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
|
|
718
|
+
case 'settings':
|
|
719
|
+
return ` ${bold('t')} threshold ${bold('p')} probe ${bold('m')} sx-mode ${bold('k')} sx-key ${bold('x')} clear-key ${bold('Esc')} back`;
|
|
508
720
|
case 'select': {
|
|
509
|
-
const act = this.selAction === 'switch' ? 'switch'
|
|
721
|
+
const act = this.selAction === 'switch' ? 'switch'
|
|
722
|
+
: this.selAction === 'toggle' ? 'enable/disable'
|
|
723
|
+
: 'remove';
|
|
510
724
|
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
|
|
511
725
|
}
|
|
512
726
|
case 'add':
|
|
@@ -0,0 +1,85 @@
|
|
|
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 https from 'node:https';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
import { tunnelTls } from './sx.js';
|
|
15
|
+
|
|
16
|
+
// `useProxy` is decided by the caller (it varies per attempt — e.g. direct first,
|
|
17
|
+
// then via sx after a 429). With it false, or sx unprovisioned, this is plain fetch.
|
|
18
|
+
export function upstreamFetch(url, opts = {}, sx = null, useProxy = false) {
|
|
19
|
+
if (!sx || !useProxy || !sx.isProvisioned()) return fetch(url, opts);
|
|
20
|
+
return proxiedFetch(url, opts, sx);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function proxiedFetch(url, opts, sx) {
|
|
24
|
+
const u = new URL(url);
|
|
25
|
+
const proxy = sx.getProxy();
|
|
26
|
+
|
|
27
|
+
// Custom agent: every socket is a TLS connection tunneled through sx.org.
|
|
28
|
+
const agent = new https.Agent({ keepAlive: true });
|
|
29
|
+
agent.createConnection = (_options, cb) => {
|
|
30
|
+
// sx.tlsOptions is undefined in production (system CAs verify api.anthropic.com);
|
|
31
|
+
// tests inject a CA here to reach a self-signed upstream.
|
|
32
|
+
tunnelTls({ proxy, targetHost: u.hostname, targetPort: Number(u.port) || 443, tlsOptions: sx.tlsOptions || {} })
|
|
33
|
+
.then((sock) => cb(null, sock))
|
|
34
|
+
.catch((err) => cb(err));
|
|
35
|
+
return undefined; // socket delivered asynchronously via cb
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const req = https.request(
|
|
40
|
+
u,
|
|
41
|
+
{ method: opts.method || 'GET', headers: opts.headers || {}, agent },
|
|
42
|
+
(res) => resolve(makeResponse(res)),
|
|
43
|
+
);
|
|
44
|
+
req.once('error', reject);
|
|
45
|
+
|
|
46
|
+
const body = opts.body;
|
|
47
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
48
|
+
if (body == null || method === 'GET' || method === 'HEAD') req.end();
|
|
49
|
+
else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) req.end(Buffer.from(body));
|
|
50
|
+
else req.end(String(body));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Wrap a Node IncomingMessage as the subset of a fetch Response that server.js uses.
|
|
55
|
+
function makeResponse(res) {
|
|
56
|
+
const web = Readable.toWeb(res); // single web stream — one consumer either way
|
|
57
|
+
const collect = async () => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
const reader = web.getReader();
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
chunks.push(Buffer.from(value));
|
|
64
|
+
}
|
|
65
|
+
return Buffer.concat(chunks);
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
status: res.statusCode,
|
|
69
|
+
headers: makeHeaders(res.headers),
|
|
70
|
+
body: web,
|
|
71
|
+
async text() { return (await collect()).toString('utf8'); },
|
|
72
|
+
async arrayBuffer() { const b = await collect(); return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// res.headers already has lowercased keys; values are string | string[] (set-cookie).
|
|
77
|
+
function makeHeaders(h) {
|
|
78
|
+
const flat = (v) => (Array.isArray(v) ? v.join(', ') : v);
|
|
79
|
+
const entries = function* () { for (const [k, v] of Object.entries(h)) yield [k, flat(v)]; };
|
|
80
|
+
return {
|
|
81
|
+
get: (name) => { const v = h[name.toLowerCase()]; return v == null ? null : flat(v); },
|
|
82
|
+
entries,
|
|
83
|
+
[Symbol.iterator]: entries,
|
|
84
|
+
};
|
|
85
|
+
}
|