@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/src/warmer.js ADDED
@@ -0,0 +1,237 @@
1
+ // Opt-in "keep-warm" scheduler (issue #76).
2
+ //
3
+ // DISABLED BY DEFAULT. When enabled (config.warmupSeconds > 0), periodically
4
+ // starts the rolling 5-hour session window on idle accounts, so that when the
5
+ // active account runs out the next one is not stone cold.
6
+ //
7
+ // This is the SECOND sanctioned active-upstream feature (the quota probe is the
8
+ // first). It differs in an important way and is why it is strictly opt-in: the
9
+ // 5h timer only starts on *real usage*, so — unlike the zero-spend
10
+ // /api/oauth/usage probe — warming genuinely consumes a little quota (a few
11
+ // tokens, a slice of the 5h window, a touch of the weekly bucket) per account
12
+ // per window. To keep that cost minimal we warm an account only when its 5h
13
+ // window is not already running, and we use the cheapest model.
14
+ //
15
+ // Mechanism (chosen in #76): for each eligible idle account we spawn a one-shot,
16
+ // minimal `claude` (`--bare -p`) pointed at THIS proxy with the account pinned
17
+ // via the `/tc-acct/<index>` path prefix. Using the real client means the
18
+ // warm-up request is byte-identical to normal Claude Code traffic, routed to
19
+ // exactly the account we want to warm.
20
+
21
+ import { spawn } from 'node:child_process';
22
+ import { encodePinComponent } from './claude-env.js';
23
+
24
+ export class Warmer {
25
+ constructor(accountManager, {
26
+ intervalMs = 0,
27
+ port,
28
+ apiKey = null,
29
+ model = 'haiku',
30
+ prompt = 'hi',
31
+ spawnFn = defaultSpawn,
32
+ timeoutMs = 120_000,
33
+ log = console.log,
34
+ } = {}) {
35
+ this.am = accountManager;
36
+ this.intervalMs = intervalMs;
37
+ this.port = port;
38
+ this.apiKey = apiKey;
39
+ this.model = model;
40
+ this.prompt = prompt;
41
+ this.spawnFn = spawnFn;
42
+ this.timeoutMs = timeoutMs;
43
+ this.log = log;
44
+ this.timer = null;
45
+ this._running = false;
46
+ this._abort = null; // AbortController for the in-flight sweep (see warmAll/stop)
47
+ this.lastRunStartedAt = null;
48
+ this.lastRunFinishedAt = null;
49
+ this.nextRunAt = intervalMs > 0 ? Date.now() + intervalMs : null;
50
+ this.accountStatus = new Map();
51
+ }
52
+
53
+ start() {
54
+ if (this.intervalMs > 0) this.reschedule(this.intervalMs);
55
+ }
56
+
57
+ /** Change interval at runtime (0 = off). Warms once immediately when turned on. */
58
+ reschedule(intervalMs) {
59
+ const wasOn = this.intervalMs > 0 && this.timer;
60
+ this.intervalMs = intervalMs;
61
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
62
+
63
+ if (intervalMs > 0) {
64
+ this.nextRunAt = Date.now() + intervalMs;
65
+ // Immediate sweep only on an off→on transition. Re-running it on every
66
+ // interval *change* would spend quota each time the interval is edited.
67
+ if (!wasOn) this.warmAll().catch(() => {});
68
+ this.timer = setInterval(() => this.warmAll().catch(() => {}), intervalMs);
69
+ this.timer.unref?.();
70
+ this.log(`[TeamClaude] Keep-warm enabled (every ${Math.round(intervalMs / 1000)}s)`);
71
+ } else if (wasOn) {
72
+ this.nextRunAt = null;
73
+ this.log('[TeamClaude] Keep-warm disabled');
74
+ }
75
+ }
76
+
77
+ stop() {
78
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
79
+ this.nextRunAt = null;
80
+ // Cancel an in-flight sweep and kill any child it spawned, so shutdown /
81
+ // `warmup off` doesn't block on a running warm-up or orphan a `claude`.
82
+ this._abort?.abort();
83
+ }
84
+
85
+ /**
86
+ * True when `account` is a healthy, idle Anthropic OAuth account whose 5h
87
+ * window is NOT already running. We skip:
88
+ * - non-OAuth and third-party-backend accounts (`upstream` set) — the 5h
89
+ * concept is Anthropic-specific;
90
+ * - disabled / errored / exhausted / throttled accounts — warming them is
91
+ * pointless or would just 429;
92
+ * - accounts with a live 5h window — already warm, so warming again only burns
93
+ * quota for nothing.
94
+ */
95
+ _isWarmTarget(account) {
96
+ if (account.type !== 'oauth' || !account.credential) return false;
97
+ if (account.upstream) return false;
98
+ if (account.disabled) return false;
99
+ if (account.status === 'error' || account.status === 'exhausted' || account.status === 'throttled') return false;
100
+ const reset = account.quota?.unified5hReset;
101
+ return !(reset && Date.now() < reset); // a future reset ⇒ session already running
102
+ }
103
+
104
+ /** Warm every eligible account once. Overlapping cycles are skipped. Sequential
105
+ * on purpose: one subprocess at a time keeps load and the quota burst gentle. */
106
+ async warmAll() {
107
+ if (this._running) return;
108
+ this._running = true;
109
+ const abort = this._abort = new AbortController();
110
+ this.lastRunStartedAt = Date.now();
111
+ this.nextRunAt = this.intervalMs > 0 ? this.lastRunStartedAt + this.intervalMs : null;
112
+ try {
113
+ const targets = this.am.accounts.filter(account => this._isWarmTarget(account));
114
+ for (const account of targets) {
115
+ if (abort.signal.aborted) break; // stopped mid-sweep (shutdown / warmup off)
116
+ await this.warmAccount(account, abort.signal);
117
+ }
118
+ } finally {
119
+ this.lastRunFinishedAt = Date.now();
120
+ this._running = false;
121
+ if (this._abort === abort) this._abort = null;
122
+ }
123
+ }
124
+
125
+ async warmAccount(account, signal) {
126
+ const startedAt = Date.now();
127
+ this._record(account, { status: 'running', startedAt });
128
+ try {
129
+ await this.am.ensureTokenFresh(account.index);
130
+ const code = await this.spawnFn(this._spawnSpec(account, signal));
131
+ const finishedAt = Date.now();
132
+ this._record(account, {
133
+ status: code === 0 ? 'ok' : 'error',
134
+ error: code === 0 ? null : `claude exited ${code}`,
135
+ startedAt, finishedAt, durationMs: finishedAt - startedAt,
136
+ });
137
+ } catch (err) {
138
+ const finishedAt = Date.now();
139
+ this._record(account, {
140
+ status: 'error',
141
+ error: err?.message || String(err),
142
+ startedAt, finishedAt, durationMs: finishedAt - startedAt,
143
+ });
144
+ }
145
+ }
146
+
147
+ /** The `claude` invocation for one account. Pure/deterministic so tests can
148
+ * assert the args and env without spawning anything. */
149
+ _spawnSpec(account, signal) {
150
+ // Pin by accountUuid — a stable identity. The rotation index is NOT usable:
151
+ // it is array position, so removing an account would repoint this at a
152
+ // different one. Fall back to the display name when the uuid isn't known
153
+ // yet (e.g. an API-key account, or before the first profile fetch).
154
+ const pin = encodePinComponent(account.accountUuid || account.name);
155
+ const baseUrl = `http://127.0.0.1:${this.port}/tc-acct/${pin}`;
156
+ return {
157
+ command: 'claude',
158
+ // `--bare -p`: minimal, non-interactive, auth strictly via ANTHROPIC_API_KEY
159
+ // (which this proxy strips and replaces with the pinned account's token).
160
+ args: ['-p', '--bare', '--model', this.model, '--output-format', 'text', this.prompt],
161
+ env: {
162
+ ...process.env,
163
+ ANTHROPIC_BASE_URL: baseUrl,
164
+ ANTHROPIC_API_KEY: this.apiKey || 'tc-warm',
165
+ },
166
+ timeoutMs: this.timeoutMs,
167
+ signal, // aborts (and kills the child) when the warmer is stopped
168
+ };
169
+ }
170
+
171
+ getStatus() {
172
+ return {
173
+ enabled: this.intervalMs > 0,
174
+ intervalSeconds: Math.round(this.intervalMs / 1000),
175
+ running: this._running,
176
+ lastRunStartedAt: iso(this.lastRunStartedAt),
177
+ lastRunFinishedAt: iso(this.lastRunFinishedAt),
178
+ nextRunAt: iso(this.nextRunAt),
179
+ accounts: this.am.accounts.map(account => {
180
+ const status = this.accountStatus.get(account.name);
181
+ const applicable = account.type === 'oauth' && !account.upstream;
182
+ return {
183
+ name: account.name,
184
+ status: applicable ? (status?.status || 'never') : 'not-applicable',
185
+ lastWarmedAt: iso(status?.finishedAt),
186
+ startedAt: iso(status?.startedAt),
187
+ durationMs: status?.durationMs ?? null,
188
+ error: status?.error || null,
189
+ };
190
+ }),
191
+ };
192
+ }
193
+
194
+ _record(account, status) {
195
+ this.accountStatus.set(account.name, {
196
+ ...(this.accountStatus.get(account.name) || {}),
197
+ ...status,
198
+ });
199
+ }
200
+ }
201
+
202
+ // Spawn a one-shot `claude`, resolving with its exit code (non-zero ⇒ recorded as
203
+ // an error) or rejecting if the binary can't launch (e.g. not on PATH) or the
204
+ // warm-up overruns its timeout. stdio is ignored: we only care that a request
205
+ // went through to start the timer.
206
+ function defaultSpawn({ command, args, env, timeoutMs, signal }) {
207
+ return new Promise((resolve, reject) => {
208
+ if (signal?.aborted) { reject(new Error('warm-up aborted')); return; }
209
+ let child;
210
+ try {
211
+ child = spawn(command, args, { env, stdio: 'ignore' });
212
+ } catch (err) {
213
+ reject(err);
214
+ return;
215
+ }
216
+ const onAbort = () => child.kill('SIGKILL');
217
+ signal?.addEventListener('abort', onAbort, { once: true });
218
+ const timer = setTimeout(() => {
219
+ child.kill('SIGKILL');
220
+ reject(new Error(`warm-up timed out after ${timeoutMs}ms`));
221
+ }, timeoutMs);
222
+ timer.unref?.();
223
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); };
224
+ child.once('error', (err) => { cleanup(); reject(err); });
225
+ child.once('exit', (code, sigName) => {
226
+ cleanup();
227
+ // A signal-killed child (OOM, external kill, our own abort) did NOT
228
+ // complete a warm-up — report it as an error, not a success (code null).
229
+ if (sigName) { reject(new Error(`claude terminated by ${sigName}`)); return; }
230
+ resolve(code ?? 0);
231
+ });
232
+ });
233
+ }
234
+
235
+ function iso(ts) {
236
+ return ts ? new Date(ts).toISOString() : null;
237
+ }
package/src/x509.js ADDED
@@ -0,0 +1,166 @@
1
+ // Minimal pure-JS X.509 certificate generation (no external deps).
2
+ //
3
+ // node:crypto can create keypairs and sign, but cannot issue certificates, so
4
+ // we hand-encode the (small) ASN.1 DER cert envelope and sign the TBS with the
5
+ // issuer key. Used only to mint a local CA + a leaf for the MITM proxy, which
6
+ // the launched claude process trusts via NODE_EXTRA_CA_CERTS. Nothing here is a
7
+ // general-purpose ASN.1 library — just what these two certs need.
8
+
9
+ import { generateKeyPairSync, sign as cryptoSign, randomBytes } from 'node:crypto';
10
+
11
+ // ── ASN.1 DER primitives ──────────────────────────────────────
12
+
13
+ function derLen(n) {
14
+ if (n < 0x80) return Buffer.from([n]);
15
+ const bytes = [];
16
+ let x = n;
17
+ while (x > 0) { bytes.unshift(x & 0xff); x = Math.floor(x / 256); }
18
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
19
+ }
20
+
21
+ function tlv(tag, content) {
22
+ return Buffer.concat([Buffer.from([tag]), derLen(content.length), content]);
23
+ }
24
+
25
+ const seq = (items) => tlv(0x30, Buffer.concat(items));
26
+ const set = (items) => tlv(0x31, Buffer.concat(items));
27
+ const NULL = Buffer.from([0x05, 0x00]);
28
+ const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0x00]));
29
+ const octet = (buf) => tlv(0x04, buf);
30
+ const bitString = (buf) => tlv(0x03, Buffer.concat([Buffer.from([0]), buf])); // 0 unused bits
31
+ const utf8 = (s) => tlv(0x0c, Buffer.from(s, 'utf8'));
32
+ const explicit = (n, content) => tlv(0xa0 | n, content); // [n] constructed
33
+ const ctxPrim = (n, content) => tlv(0x80 | n, content); // [n] primitive
34
+
35
+ function integer(buf) {
36
+ let b = Buffer.isBuffer(buf) ? Buffer.from(buf) : Buffer.from([buf]);
37
+ let i = 0;
38
+ while (i < b.length - 1 && b[i] === 0) i++; // strip leading zeros
39
+ b = b.subarray(i);
40
+ if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]); // keep positive
41
+ return tlv(0x02, b);
42
+ }
43
+
44
+ function oid(dotted) {
45
+ const parts = dotted.split('.').map(Number);
46
+ const out = [40 * parts[0] + parts[1]];
47
+ for (let i = 2; i < parts.length; i++) {
48
+ let v = parts[i];
49
+ const group = [v & 0x7f];
50
+ v = Math.floor(v / 128);
51
+ while (v > 0) { group.unshift((v & 0x7f) | 0x80); v = Math.floor(v / 128); }
52
+ out.push(...group);
53
+ }
54
+ return tlv(0x06, Buffer.from(out));
55
+ }
56
+
57
+ function utcTime(date) {
58
+ const z = (n) => String(n).padStart(2, '0');
59
+ const s = `${z(date.getUTCFullYear() % 100)}${z(date.getUTCMonth() + 1)}${z(date.getUTCDate())}` +
60
+ `${z(date.getUTCHours())}${z(date.getUTCMinutes())}${z(date.getUTCSeconds())}Z`;
61
+ return tlv(0x17, Buffer.from(s, 'ascii'));
62
+ }
63
+
64
+ function pem(der, label) {
65
+ const b64 = der.toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '');
66
+ return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`;
67
+ }
68
+
69
+ // ── cert pieces ───────────────────────────────────────────────
70
+
71
+ const SIG_ALG = seq([oid('1.2.840.113549.1.1.11'), NULL]); // sha256WithRSAEncryption
72
+
73
+ function nameCN(cn) {
74
+ return seq([set([seq([oid('2.5.4.3'), utf8(cn)])])]); // RDNSequence with one CN
75
+ }
76
+
77
+ function ext(extOid, critical, valueDer) {
78
+ const items = [oid(extOid)];
79
+ if (critical) items.push(bool(true));
80
+ items.push(octet(valueDer));
81
+ return seq(items);
82
+ }
83
+
84
+ // keyUsage BIT STRING from named bit positions (bit 0 = MSB of first byte).
85
+ function keyUsage(bits) {
86
+ const max = Math.max(...bits);
87
+ const nbytes = Math.floor(max / 8) + 1;
88
+ const bytes = Buffer.alloc(nbytes);
89
+ for (const b of bits) bytes[Math.floor(b / 8)] |= 0x80 >> (b % 8);
90
+ const unused = nbytes * 8 - (max + 1);
91
+ return tlv(0x03, Buffer.concat([Buffer.from([unused]), bytes]));
92
+ }
93
+
94
+ function buildCert({ subjectCN, issuerCN, spkiDer, signKey, isCA, altDnsNames = [], days }) {
95
+ const now = new Date();
96
+ const notBefore = new Date(now.getTime() - 60 * 60 * 1000); // 1h back for clock skew
97
+ const notAfter = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
98
+
99
+ const extList = [];
100
+ extList.push(ext('2.5.29.19', true, isCA ? seq([bool(true)]) : seq([]))); // basicConstraints
101
+ extList.push(ext('2.5.29.15', true, isCA
102
+ ? keyUsage([0, 5, 6]) // digitalSignature, keyCertSign, cRLSign
103
+ : keyUsage([0, 2]))); // digitalSignature, keyEncipherment
104
+ if (!isCA) {
105
+ extList.push(ext('2.5.29.37', false, seq([oid('1.3.6.1.5.5.7.3.1')]))); // extKeyUsage serverAuth
106
+ if (altDnsNames.length) {
107
+ extList.push(ext('2.5.29.17', false, seq(altDnsNames.map((d) => ctxPrim(2, Buffer.from(d)))))); // SAN dNSName
108
+ }
109
+ }
110
+
111
+ const tbs = seq([
112
+ explicit(0, integer(Buffer.from([2]))), // version v3
113
+ integer(randomBytes(16)), // serial
114
+ SIG_ALG,
115
+ nameCN(issuerCN),
116
+ seq([utcTime(notBefore), utcTime(notAfter)]),
117
+ nameCN(subjectCN),
118
+ spkiDer, // SubjectPublicKeyInfo (already DER)
119
+ explicit(3, seq(extList)),
120
+ ]);
121
+
122
+ const signature = cryptoSign('sha256', tbs, signKey); // RSASSA-PKCS1-v1_5
123
+ return pem(seq([tbs, SIG_ALG, bitString(signature)]), 'CERTIFICATE');
124
+ }
125
+
126
+ // ── public API ────────────────────────────────────────────────
127
+
128
+ function newRsaKey() {
129
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
130
+ return {
131
+ privateKey,
132
+ keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
133
+ spkiDer: publicKey.export({ type: 'spki', format: 'der' }),
134
+ };
135
+ }
136
+
137
+ export function createCA(cn = 'TeamClaude Local CA') {
138
+ const key = newRsaKey();
139
+ const certPem = buildCert({
140
+ subjectCN: cn, issuerCN: cn, spkiDer: key.spkiDer, signKey: key.privateKey,
141
+ isCA: true, days: 3650,
142
+ });
143
+ return { cn, certPem, keyPem: key.keyPem, privateKey: key.privateKey };
144
+ }
145
+
146
+ export function createLeaf(hosts, ca) {
147
+ const list = Array.isArray(hosts) ? hosts : [hosts];
148
+ const key = newRsaKey();
149
+ const certPem = buildCert({
150
+ subjectCN: list[0], issuerCN: ca.cn, spkiDer: key.spkiDer, signKey: ca.privateKey,
151
+ isCA: false, altDnsNames: list, days: 825,
152
+ });
153
+ return { certPem, keyPem: key.keyPem };
154
+ }
155
+
156
+ /** Generate a fresh CA + a leaf covering `hosts` (string or array). Returns PEM strings. */
157
+ export function generateCertChain(hosts) {
158
+ const ca = createCA();
159
+ const leaf = createLeaf(hosts, ca);
160
+ return {
161
+ caCertPem: ca.certPem,
162
+ caKeyPem: ca.keyPem,
163
+ leafCertPem: leaf.certPem,
164
+ leafKeyPem: leaf.keyPem,
165
+ };
166
+ }