@triflux/core 10.51.0 → 10.53.0
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/hub/bridge.mjs +69 -1197
- package/hub/cli-adapter-base.mjs +4 -104
- package/hub/codex-adapter.mjs +7 -116
- package/hub/index.mjs +3 -20
- package/hub/lib/process-utils.mjs +1 -33
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- package/hub/team/claude-transcript.mjs +19 -0
- package/hub/team/nativeProxy.mjs +759 -0
- package/hub/team/session-context.mjs +1 -1
- package/hud/constants.mjs +9 -22
- package/hud/hud-qos-status.mjs +16 -19
- package/hud/providers/codex-probe.mjs +2 -31
- package/hud/providers/codex.mjs +16 -58
- package/hud/providers/gemini.mjs +20 -0
- package/hud/renderers.mjs +101 -77
- package/hud/utils.mjs +9 -13
- package/package.json +3 -6
- package/scripts/lib/agent-route-policy.mjs +0 -67
- package/scripts/lib/cli-claude.mjs +0 -12
- package/scripts/lib/env-probe.mjs +0 -70
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/mcp-guard-engine.mjs +42 -101
- package/scripts/lib/session-presence.mjs +1 -99
- package/hub/account-broker.mjs +0 -1221
- package/hub/adaptive-diagnostic.mjs +0 -350
- package/hub/adaptive-inject.mjs +0 -224
- package/hub/adaptive-memory.mjs +0 -277
- package/hub/adaptive.mjs +0 -172
- package/hub/assign-callbacks.mjs +0 -170
- package/hub/delegator/contracts.mjs +0 -38
- package/hub/delegator/index.mjs +0 -14
- package/hub/delegator/schema/delegator-tools.schema.json +0 -250
- package/hub/delegator/service.mjs +0 -336
- package/hub/delegator/tool-definitions.mjs +0 -35
- package/hub/hitl.mjs +0 -202
- package/hub/lib/known-errors.json +0 -72
- package/hub/lib/memory-store.mjs +0 -1001
- package/hub/lib/phase-manager.mjs +0 -493
- package/hub/lib/state-snapshot.mjs +0 -293
- package/hub/lib/trace-recorder.mjs +0 -153
- package/hub/lib/uuidv7.mjs +0 -45
- package/hub/memory-doctor.mjs +0 -640
- package/hub/middleware/quota-middleware.mjs +0 -70
- package/hub/middleware/request-logger.mjs +0 -191
- package/hub/paths.mjs +0 -24
- package/hub/pipeline/index.mjs +0 -163
- package/hub/pipeline/state.mjs +0 -191
- package/hub/pipeline/transitions.mjs +0 -131
- package/hub/reflexion.mjs +0 -290
- package/hub/role-contract.mjs +0 -312
- package/hub/router.mjs +0 -2026
- package/hub/session-fingerprint.mjs +0 -418
- package/hub/state.mjs +0 -300
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team-bridge.mjs +0 -27
- package/scripts/lib/claudemd-scanner.mjs +0 -26
- package/scripts/lib/context.mjs +0 -51
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
package/hub/account-broker.mjs
DELETED
|
@@ -1,1221 +0,0 @@
|
|
|
1
|
-
// hub/account-broker.mjs — Multi-account CLI pool broker
|
|
2
|
-
// Manages lease/release/cooldown/circuit-breaker for Codex and Gemini accounts.
|
|
3
|
-
// Per-account circuit breaker: one bad account does not block others.
|
|
4
|
-
// Singleton export. All state changes create new objects (immutable pattern).
|
|
5
|
-
|
|
6
|
-
import { EventEmitter } from "node:events";
|
|
7
|
-
import {
|
|
8
|
-
chmodSync,
|
|
9
|
-
closeSync,
|
|
10
|
-
existsSync,
|
|
11
|
-
constants as fsConstants,
|
|
12
|
-
mkdirSync,
|
|
13
|
-
openSync,
|
|
14
|
-
readFileSync,
|
|
15
|
-
renameSync,
|
|
16
|
-
statSync,
|
|
17
|
-
unlinkSync,
|
|
18
|
-
writeFileSync,
|
|
19
|
-
} from "node:fs";
|
|
20
|
-
import { homedir } from "node:os";
|
|
21
|
-
import { dirname, join } from "node:path";
|
|
22
|
-
import * as z from "zod";
|
|
23
|
-
|
|
24
|
-
import { isPathWithin } from "./platform.mjs";
|
|
25
|
-
|
|
26
|
-
// ── Zod schema ───────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
const AccountSchema = z
|
|
29
|
-
.object({
|
|
30
|
-
id: z.string().min(1),
|
|
31
|
-
mode: z.enum(["profile", "env", "auth"]),
|
|
32
|
-
profile: z.string().optional(),
|
|
33
|
-
env: z.record(z.string(), z.string()).optional(),
|
|
34
|
-
authFile: z.string().optional(),
|
|
35
|
-
host: z.string().min(1).optional(),
|
|
36
|
-
tier: z
|
|
37
|
-
.enum(["pro", "plus", "free", "unknown"])
|
|
38
|
-
.optional()
|
|
39
|
-
.default("unknown"),
|
|
40
|
-
})
|
|
41
|
-
.superRefine((val, ctx) => {
|
|
42
|
-
if (val.mode === "auth" && !val.authFile) {
|
|
43
|
-
ctx.addIssue({
|
|
44
|
-
code: z.ZodIssueCode.custom,
|
|
45
|
-
message: 'authFile is required when mode is "auth"',
|
|
46
|
-
path: ["authFile"],
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const ConfigSchema = z.object({
|
|
52
|
-
defaults: z
|
|
53
|
-
.object({
|
|
54
|
-
cooldownMs: z.number().int().positive().optional(),
|
|
55
|
-
})
|
|
56
|
-
.optional(),
|
|
57
|
-
codex: z.array(AccountSchema).optional(),
|
|
58
|
-
gemini: z.array(AccountSchema).optional(),
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const DEFAULT_COOLDOWN_MS = 300_000; // 5 minutes
|
|
62
|
-
const _QUOTA_COOLDOWN_MS = {
|
|
63
|
-
codex: 5 * 60 * 60_000, // 5 hours (단기 쿼터)
|
|
64
|
-
codex_weekly: 7 * 24 * 60 * 60_000, // 7 days (주간 쿼터)
|
|
65
|
-
gemini: 24 * 60 * 60_000, // 24 hours
|
|
66
|
-
};
|
|
67
|
-
const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
|
|
68
|
-
const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
69
|
-
const CIRCUIT_WINDOW_MS = 10 * 60_000; // 10 minutes
|
|
70
|
-
const CIRCUIT_MAX_FAILURES = 3;
|
|
71
|
-
function getAuthBasePath() {
|
|
72
|
-
return join(homedir(), ".claude", "cache", "tfx-hub");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function getCodexAuthSourcePath() {
|
|
76
|
-
return join(homedir(), ".codex", "auth.json");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function getStatePersistPath() {
|
|
80
|
-
return join(getAuthBasePath(), "broker-state.json");
|
|
81
|
-
}
|
|
82
|
-
const AUTH_SYNC_LOCK_TIMEOUT_MS = 5_000;
|
|
83
|
-
const AUTH_SYNC_LOCK_RETRY_MS = 25;
|
|
84
|
-
const AUTH_SYNC_LOCK_STALE_MS = 30_000;
|
|
85
|
-
const AUTH_SYNC_SAB = new Int32Array(new SharedArrayBuffer(4));
|
|
86
|
-
|
|
87
|
-
// ── State persistence ────────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
function persistState(stateMap) {
|
|
90
|
-
try {
|
|
91
|
-
const authBasePath = getAuthBasePath();
|
|
92
|
-
const statePersistPath = getStatePersistPath();
|
|
93
|
-
const now = Date.now();
|
|
94
|
-
const entries = {};
|
|
95
|
-
for (const [id, acct] of stateMap) {
|
|
96
|
-
// 활성 쿨다운 또는 circuit open만 저장 (불필요한 데이터 제거)
|
|
97
|
-
if (
|
|
98
|
-
acct.cooldownUntil > now ||
|
|
99
|
-
acct.circuitOpenedAt > 0 ||
|
|
100
|
-
acct.totalSessions > 0
|
|
101
|
-
) {
|
|
102
|
-
entries[id] = {
|
|
103
|
-
cooldownUntil: acct.cooldownUntil,
|
|
104
|
-
circuitOpenedAt: acct.circuitOpenedAt,
|
|
105
|
-
failureTimestamps: acct.failureTimestamps,
|
|
106
|
-
totalSessions: acct.totalSessions,
|
|
107
|
-
lastUsedAt: acct.lastUsedAt,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
mkdirSync(authBasePath, { recursive: true });
|
|
112
|
-
writeFileSync(statePersistPath, JSON.stringify({ ts: now, entries }));
|
|
113
|
-
} catch (err) {
|
|
114
|
-
try {
|
|
115
|
-
console.error("[account-broker] persistState failed:", err.message);
|
|
116
|
-
} catch {}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function loadPersistedState() {
|
|
121
|
-
try {
|
|
122
|
-
const statePersistPath = getStatePersistPath();
|
|
123
|
-
if (!existsSync(statePersistPath)) return null;
|
|
124
|
-
return JSON.parse(readFileSync(statePersistPath, "utf8"));
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ── env var resolution ───────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
function resolveEnvValues(env) {
|
|
133
|
-
if (!env) return undefined;
|
|
134
|
-
const resolved = {};
|
|
135
|
-
for (const [key, value] of Object.entries(env)) {
|
|
136
|
-
if (typeof value === "string" && value.startsWith("$")) {
|
|
137
|
-
const varName = value.slice(1);
|
|
138
|
-
resolved[key] = process.env[varName] ?? "";
|
|
139
|
-
} else {
|
|
140
|
-
resolved[key] = value;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return resolved;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function isRemoteAccount(account) {
|
|
147
|
-
return Boolean(account.host);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function getRemainingLeaseMs(account, now) {
|
|
151
|
-
if (!account.busy || account.leasedAt === null) return 0;
|
|
152
|
-
return Math.max(0, LEASE_TTL_MS - (now - account.leasedAt));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function sleepSync(ms) {
|
|
156
|
-
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
157
|
-
Atomics.wait(AUTH_SYNC_SAB, 0, 0, ms);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function statOrNull(filePath) {
|
|
161
|
-
try {
|
|
162
|
-
return statSync(filePath);
|
|
163
|
-
} catch (error) {
|
|
164
|
-
if (error?.code === "ENOENT") return null;
|
|
165
|
-
throw error;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function readAuthPayload(filePath) {
|
|
170
|
-
const buffer = readFileSync(filePath);
|
|
171
|
-
const parsed = JSON.parse(buffer.toString("utf8"));
|
|
172
|
-
return {
|
|
173
|
-
buffer,
|
|
174
|
-
parsed,
|
|
175
|
-
accountId:
|
|
176
|
-
parsed?.tokens?.account_id ??
|
|
177
|
-
parsed?.account_id ??
|
|
178
|
-
parsed?.accountId ??
|
|
179
|
-
null,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function createSyncResult({
|
|
184
|
-
accountId,
|
|
185
|
-
direction,
|
|
186
|
-
sourcePath,
|
|
187
|
-
cachePath,
|
|
188
|
-
runtimePath,
|
|
189
|
-
copied = false,
|
|
190
|
-
skipped = false,
|
|
191
|
-
reason = "ok",
|
|
192
|
-
}) {
|
|
193
|
-
return {
|
|
194
|
-
ok: !skipped || reason === "up_to_date",
|
|
195
|
-
accountId,
|
|
196
|
-
direction,
|
|
197
|
-
sourcePath,
|
|
198
|
-
cachePath,
|
|
199
|
-
runtimePath,
|
|
200
|
-
copied,
|
|
201
|
-
skipped,
|
|
202
|
-
reason,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function withLockFile(lockPath, opts, task) {
|
|
207
|
-
const retryMs = opts.retryMs ?? AUTH_SYNC_LOCK_RETRY_MS;
|
|
208
|
-
const timeoutMs = opts.timeoutMs ?? AUTH_SYNC_LOCK_TIMEOUT_MS;
|
|
209
|
-
const staleMs = opts.staleMs ?? AUTH_SYNC_LOCK_STALE_MS;
|
|
210
|
-
const start = Date.now();
|
|
211
|
-
|
|
212
|
-
while (true) {
|
|
213
|
-
let fd;
|
|
214
|
-
try {
|
|
215
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
216
|
-
fd = openSync(
|
|
217
|
-
lockPath,
|
|
218
|
-
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL,
|
|
219
|
-
0o600,
|
|
220
|
-
);
|
|
221
|
-
writeFileSync(fd, `${process.pid}\n${Date.now()}`, "utf8");
|
|
222
|
-
try {
|
|
223
|
-
return task();
|
|
224
|
-
} finally {
|
|
225
|
-
closeSync(fd);
|
|
226
|
-
try {
|
|
227
|
-
unlinkSync(lockPath);
|
|
228
|
-
} catch {}
|
|
229
|
-
}
|
|
230
|
-
} catch (error) {
|
|
231
|
-
if (fd !== undefined) {
|
|
232
|
-
try {
|
|
233
|
-
closeSync(fd);
|
|
234
|
-
} catch {}
|
|
235
|
-
}
|
|
236
|
-
if (error?.code !== "EEXIST") throw error;
|
|
237
|
-
|
|
238
|
-
const lockStat = statOrNull(lockPath);
|
|
239
|
-
if (lockStat && Date.now() - lockStat.mtimeMs > staleMs) {
|
|
240
|
-
try {
|
|
241
|
-
unlinkSync(lockPath);
|
|
242
|
-
continue;
|
|
243
|
-
} catch {}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (Date.now() - start >= timeoutMs) {
|
|
247
|
-
return { lockTimeout: true };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
sleepSync(retryMs);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── AccountBroker ────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* AccountBroker는 codex/gemini 계정 풀 + lease/cooldown/circuit breaker 를 관리한다.
|
|
259
|
-
*
|
|
260
|
-
* **Resolved-at-construction 정책 (lazy/eager mix 주의)**
|
|
261
|
-
*
|
|
262
|
-
* `getAuthBasePath()` / `getCodexAuthSourcePath()` 는 호출 시점에 `homedir()` 를
|
|
263
|
-
* 다시 평가하는 lazy getter 다. 그러나 default 파라미터 (`_authBasePath = getAuthBasePath()`)
|
|
264
|
-
* 는 생성자 호출 시점에 한 번만 평가되고 결과가 `#authBasePath` 등 private field 에 박힌다.
|
|
265
|
-
*
|
|
266
|
-
* 결과: 생성자 이후 process.env.HOME 변경은 broker 가 보는 경로에 전파되지 않는다.
|
|
267
|
-
* 테스트에서 HOME 을 갈아끼우려면 `new AccountBroker(config, { _authBasePath, _codexAuthSourcePath })`
|
|
268
|
-
* 로 오버라이드를 직접 주입하거나, broker 자체를 재생성해야 한다.
|
|
269
|
-
*/
|
|
270
|
-
class AccountBroker extends EventEmitter {
|
|
271
|
-
#config;
|
|
272
|
-
#state; // Map<accountId, accountState>
|
|
273
|
-
#roundRobinIndex; // Map<provider, number>
|
|
274
|
-
#persist; // boolean — disable persistence for tests
|
|
275
|
-
#authBasePath;
|
|
276
|
-
#codexAuthSourcePath;
|
|
277
|
-
#authSyncLockOpts;
|
|
278
|
-
#syncCopyDelayMs;
|
|
279
|
-
|
|
280
|
-
constructor(
|
|
281
|
-
config,
|
|
282
|
-
{
|
|
283
|
-
_skipPersistence = false,
|
|
284
|
-
_authBasePath = getAuthBasePath(),
|
|
285
|
-
_codexAuthSourcePath = getCodexAuthSourcePath(),
|
|
286
|
-
_authSyncLockTimeoutMs = AUTH_SYNC_LOCK_TIMEOUT_MS,
|
|
287
|
-
_authSyncLockRetryMs = AUTH_SYNC_LOCK_RETRY_MS,
|
|
288
|
-
_authSyncLockStaleMs = AUTH_SYNC_LOCK_STALE_MS,
|
|
289
|
-
_syncCopyDelayMs = 0,
|
|
290
|
-
_activeLeases = [],
|
|
291
|
-
} = {},
|
|
292
|
-
) {
|
|
293
|
-
super();
|
|
294
|
-
const parsed = ConfigSchema.parse(config);
|
|
295
|
-
this.#config = parsed;
|
|
296
|
-
this.#persist = !_skipPersistence;
|
|
297
|
-
this.#authBasePath = _authBasePath;
|
|
298
|
-
this.#codexAuthSourcePath = _codexAuthSourcePath;
|
|
299
|
-
this.#authSyncLockOpts = {
|
|
300
|
-
timeoutMs: _authSyncLockTimeoutMs,
|
|
301
|
-
retryMs: _authSyncLockRetryMs,
|
|
302
|
-
staleMs: _authSyncLockStaleMs,
|
|
303
|
-
};
|
|
304
|
-
this.#syncCopyDelayMs = _syncCopyDelayMs;
|
|
305
|
-
|
|
306
|
-
this.#state = new Map();
|
|
307
|
-
this.#roundRobinIndex = new Map();
|
|
308
|
-
|
|
309
|
-
const allAccounts = [
|
|
310
|
-
...(parsed.codex || []).map((a) => ({ ...a, provider: "codex" })),
|
|
311
|
-
...(parsed.gemini || []).map((a) => ({ ...a, provider: "gemini" })),
|
|
312
|
-
];
|
|
313
|
-
|
|
314
|
-
const persisted = this.#persist ? loadPersistedState() : null;
|
|
315
|
-
const pEntries = persisted?.entries || {};
|
|
316
|
-
|
|
317
|
-
const activeLeaseById = new Map(
|
|
318
|
-
Array.isArray(_activeLeases)
|
|
319
|
-
? _activeLeases
|
|
320
|
-
.filter((lease) => lease?.id)
|
|
321
|
-
.map((lease) => [lease.id, lease])
|
|
322
|
-
: [],
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
for (const account of allAccounts) {
|
|
326
|
-
const saved = pEntries[account.id];
|
|
327
|
-
const activeLease = activeLeaseById.get(account.id);
|
|
328
|
-
this.#state.set(account.id, {
|
|
329
|
-
id: account.id,
|
|
330
|
-
provider: account.provider,
|
|
331
|
-
mode: account.mode,
|
|
332
|
-
profile: account.profile,
|
|
333
|
-
env: account.env,
|
|
334
|
-
authFile: account.authFile,
|
|
335
|
-
host: account.host,
|
|
336
|
-
tier: account.tier ?? "unknown",
|
|
337
|
-
busy: activeLease?.busy === true,
|
|
338
|
-
leasedAt: activeLease?.leasedAt ?? null,
|
|
339
|
-
cooldownUntil: saved?.cooldownUntil ?? 0,
|
|
340
|
-
failureTimestamps: saved?.failureTimestamps ?? [],
|
|
341
|
-
circuitOpenedAt: saved?.circuitOpenedAt ?? 0,
|
|
342
|
-
circuitTrialInFlight: activeLease?.circuitTrialInFlight === true,
|
|
343
|
-
lastUsedAt: activeLease?.lastUsedAt ?? saved?.lastUsedAt ?? 0,
|
|
344
|
-
totalSessions: Math.max(
|
|
345
|
-
saved?.totalSessions ?? 0,
|
|
346
|
-
activeLease?.totalSessions ?? 0,
|
|
347
|
-
),
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
#resolveCacheAuthPath(acct) {
|
|
353
|
-
if (!acct?.authFile) return null;
|
|
354
|
-
const resolved = join(this.#authBasePath, acct.authFile);
|
|
355
|
-
if (
|
|
356
|
-
!isPathWithin(resolved, this.#authBasePath) ||
|
|
357
|
-
resolved === this.#authBasePath
|
|
358
|
-
) {
|
|
359
|
-
return null;
|
|
360
|
-
}
|
|
361
|
-
return resolved;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
#getSourceAuthPath(acct) {
|
|
365
|
-
if (!acct || acct.mode !== "auth") return null;
|
|
366
|
-
if (acct.provider === "codex") return this.#codexAuthSourcePath;
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
#getLockPath(acct) {
|
|
371
|
-
if (acct?.provider === "codex" && acct?.mode === "auth") {
|
|
372
|
-
return join(this.#authBasePath, "codex-auth-source.lock");
|
|
373
|
-
}
|
|
374
|
-
return join(this.#authBasePath, `auth-sync-${acct.id}.lock`);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
#copyAuthPayload(sourcePath, destPath) {
|
|
378
|
-
const payload = readFileSync(sourcePath);
|
|
379
|
-
if (this.#syncCopyDelayMs > 0) {
|
|
380
|
-
sleepSync(this.#syncCopyDelayMs);
|
|
381
|
-
}
|
|
382
|
-
mkdirSync(dirname(destPath), { recursive: true });
|
|
383
|
-
const tmpPath = join(
|
|
384
|
-
dirname(destPath),
|
|
385
|
-
`.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
386
|
-
);
|
|
387
|
-
try {
|
|
388
|
-
writeFileSync(tmpPath, payload, { mode: 0o600 });
|
|
389
|
-
renameSync(tmpPath, destPath);
|
|
390
|
-
try {
|
|
391
|
-
chmodSync(destPath, 0o600);
|
|
392
|
-
} catch {}
|
|
393
|
-
} catch (error) {
|
|
394
|
-
try {
|
|
395
|
-
unlinkSync(tmpPath);
|
|
396
|
-
} catch {}
|
|
397
|
-
throw error;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
#runtimeAuthPath(acct) {
|
|
402
|
-
const safeId = String(acct.id).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
403
|
-
return join(this.#authBasePath, "codex-home", safeId, "auth.json");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
#prepareRuntimeAuthFile(acct, cachePath) {
|
|
407
|
-
const runtimePath = this.#runtimeAuthPath(acct);
|
|
408
|
-
try {
|
|
409
|
-
const cacheAuth = readAuthPayload(cachePath);
|
|
410
|
-
if (!cacheAuth.accountId) {
|
|
411
|
-
return {
|
|
412
|
-
ok: false,
|
|
413
|
-
reason: "cache_account_unknown",
|
|
414
|
-
authFile: runtimePath,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
if (cacheAuth.accountId !== acct.id) {
|
|
418
|
-
return {
|
|
419
|
-
ok: false,
|
|
420
|
-
reason: "cache_account_mismatch",
|
|
421
|
-
authFile: runtimePath,
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
this.#copyAuthPayload(cachePath, runtimePath);
|
|
425
|
-
return {
|
|
426
|
-
ok: true,
|
|
427
|
-
reason: "runtime_auth_prepared",
|
|
428
|
-
authFile: runtimePath,
|
|
429
|
-
};
|
|
430
|
-
} catch (error) {
|
|
431
|
-
return {
|
|
432
|
-
ok: false,
|
|
433
|
-
reason:
|
|
434
|
-
error?.code === "ENOENT" ? "cache_missing" : "runtime_auth_error",
|
|
435
|
-
authFile: runtimePath,
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
#syncAuth(accountId, direction) {
|
|
441
|
-
const acct = this.#state.get(accountId);
|
|
442
|
-
const sourcePath = this.#getSourceAuthPath(acct);
|
|
443
|
-
const cachePath = this.#resolveCacheAuthPath(acct);
|
|
444
|
-
const runtimePath = acct ? this.#runtimeAuthPath(acct) : null;
|
|
445
|
-
|
|
446
|
-
if (!acct || acct.mode !== "auth" || acct.provider !== "codex") {
|
|
447
|
-
return createSyncResult({
|
|
448
|
-
accountId,
|
|
449
|
-
direction,
|
|
450
|
-
sourcePath,
|
|
451
|
-
cachePath,
|
|
452
|
-
runtimePath,
|
|
453
|
-
skipped: true,
|
|
454
|
-
reason: "unsupported_account",
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (!cachePath) {
|
|
459
|
-
this.emit("securityViolation", {
|
|
460
|
-
id: acct.id,
|
|
461
|
-
authFile: acct.authFile,
|
|
462
|
-
});
|
|
463
|
-
return createSyncResult({
|
|
464
|
-
accountId,
|
|
465
|
-
direction,
|
|
466
|
-
sourcePath,
|
|
467
|
-
cachePath,
|
|
468
|
-
runtimePath,
|
|
469
|
-
skipped: true,
|
|
470
|
-
reason: "invalid_cache_path",
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
if (!sourcePath) {
|
|
475
|
-
return createSyncResult({
|
|
476
|
-
accountId,
|
|
477
|
-
direction,
|
|
478
|
-
sourcePath,
|
|
479
|
-
cachePath,
|
|
480
|
-
runtimePath,
|
|
481
|
-
skipped: true,
|
|
482
|
-
reason: "unsupported_source",
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const locked = withLockFile(
|
|
487
|
-
this.#getLockPath(acct),
|
|
488
|
-
this.#authSyncLockOpts,
|
|
489
|
-
() => {
|
|
490
|
-
try {
|
|
491
|
-
if (direction === "runtime-to-source") {
|
|
492
|
-
const runtimeStat = statOrNull(runtimePath);
|
|
493
|
-
if (!runtimeStat) {
|
|
494
|
-
return createSyncResult({
|
|
495
|
-
accountId,
|
|
496
|
-
direction,
|
|
497
|
-
sourcePath,
|
|
498
|
-
cachePath,
|
|
499
|
-
runtimePath,
|
|
500
|
-
skipped: true,
|
|
501
|
-
reason: "runtime_missing",
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const runtimeAuth = readAuthPayload(runtimePath);
|
|
506
|
-
if (!runtimeAuth.accountId) {
|
|
507
|
-
return createSyncResult({
|
|
508
|
-
accountId,
|
|
509
|
-
direction,
|
|
510
|
-
sourcePath,
|
|
511
|
-
cachePath,
|
|
512
|
-
runtimePath,
|
|
513
|
-
skipped: true,
|
|
514
|
-
reason: "runtime_account_unknown",
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
if (runtimeAuth.accountId !== accountId) {
|
|
518
|
-
return createSyncResult({
|
|
519
|
-
accountId,
|
|
520
|
-
direction,
|
|
521
|
-
sourcePath,
|
|
522
|
-
cachePath,
|
|
523
|
-
runtimePath,
|
|
524
|
-
skipped: true,
|
|
525
|
-
reason: "runtime_account_mismatch",
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const cacheStat = statOrNull(cachePath);
|
|
530
|
-
let shouldCopyRuntimeToCache = !cacheStat;
|
|
531
|
-
if (cacheStat) {
|
|
532
|
-
const cacheAuth = readAuthPayload(cachePath);
|
|
533
|
-
if (!cacheAuth.accountId) {
|
|
534
|
-
return createSyncResult({
|
|
535
|
-
accountId,
|
|
536
|
-
direction,
|
|
537
|
-
sourcePath,
|
|
538
|
-
cachePath,
|
|
539
|
-
runtimePath,
|
|
540
|
-
skipped: true,
|
|
541
|
-
reason: "cache_account_unknown",
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
if (cacheAuth.accountId !== accountId) {
|
|
545
|
-
return createSyncResult({
|
|
546
|
-
accountId,
|
|
547
|
-
direction,
|
|
548
|
-
sourcePath,
|
|
549
|
-
cachePath,
|
|
550
|
-
runtimePath,
|
|
551
|
-
skipped: true,
|
|
552
|
-
reason: "cache_account_mismatch",
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
shouldCopyRuntimeToCache =
|
|
556
|
-
runtimeStat.mtimeMs > cacheStat.mtimeMs ||
|
|
557
|
-
!runtimeAuth.buffer.equals(cacheAuth.buffer);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
let copied = false;
|
|
561
|
-
if (shouldCopyRuntimeToCache) {
|
|
562
|
-
this.#copyAuthPayload(runtimePath, cachePath);
|
|
563
|
-
copied = true;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const nextCacheStat = statOrNull(cachePath);
|
|
567
|
-
const nextCacheAuth = readAuthPayload(cachePath);
|
|
568
|
-
const sourceStat = statOrNull(sourcePath);
|
|
569
|
-
let shouldCopyCacheToSource = !sourceStat;
|
|
570
|
-
if (sourceStat) {
|
|
571
|
-
const sourceAuth = readAuthPayload(sourcePath);
|
|
572
|
-
shouldCopyCacheToSource =
|
|
573
|
-
sourceAuth.accountId !== accountId ||
|
|
574
|
-
nextCacheStat.mtimeMs > sourceStat.mtimeMs ||
|
|
575
|
-
!nextCacheAuth.buffer.equals(sourceAuth.buffer);
|
|
576
|
-
}
|
|
577
|
-
if (shouldCopyCacheToSource) {
|
|
578
|
-
this.#copyAuthPayload(cachePath, sourcePath);
|
|
579
|
-
copied = true;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
return createSyncResult({
|
|
583
|
-
accountId,
|
|
584
|
-
direction,
|
|
585
|
-
sourcePath,
|
|
586
|
-
cachePath,
|
|
587
|
-
runtimePath,
|
|
588
|
-
copied,
|
|
589
|
-
skipped: !copied,
|
|
590
|
-
reason: copied ? "runtime_propagated" : "up_to_date",
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
if (direction === "from-source") {
|
|
595
|
-
const sourceStat = statOrNull(sourcePath);
|
|
596
|
-
if (!sourceStat) {
|
|
597
|
-
return createSyncResult({
|
|
598
|
-
accountId,
|
|
599
|
-
direction,
|
|
600
|
-
sourcePath,
|
|
601
|
-
cachePath,
|
|
602
|
-
runtimePath,
|
|
603
|
-
skipped: true,
|
|
604
|
-
reason: "source_missing",
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const sourceAuth = readAuthPayload(sourcePath);
|
|
609
|
-
if (!sourceAuth.accountId) {
|
|
610
|
-
return createSyncResult({
|
|
611
|
-
accountId,
|
|
612
|
-
direction,
|
|
613
|
-
sourcePath,
|
|
614
|
-
cachePath,
|
|
615
|
-
runtimePath,
|
|
616
|
-
skipped: true,
|
|
617
|
-
reason: "source_account_unknown",
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
if (sourceAuth.accountId !== accountId) {
|
|
621
|
-
return createSyncResult({
|
|
622
|
-
accountId,
|
|
623
|
-
direction,
|
|
624
|
-
sourcePath,
|
|
625
|
-
cachePath,
|
|
626
|
-
runtimePath,
|
|
627
|
-
skipped: true,
|
|
628
|
-
reason: "source_account_mismatch",
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const cacheStat = statOrNull(cachePath);
|
|
633
|
-
if (cacheStat) {
|
|
634
|
-
const cacheAuth = readAuthPayload(cachePath);
|
|
635
|
-
if (!cacheAuth.accountId) {
|
|
636
|
-
return createSyncResult({
|
|
637
|
-
accountId,
|
|
638
|
-
direction,
|
|
639
|
-
sourcePath,
|
|
640
|
-
cachePath,
|
|
641
|
-
runtimePath,
|
|
642
|
-
skipped: true,
|
|
643
|
-
reason: "cache_account_unknown",
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
if (cacheAuth.accountId !== accountId) {
|
|
647
|
-
return createSyncResult({
|
|
648
|
-
accountId,
|
|
649
|
-
direction,
|
|
650
|
-
sourcePath,
|
|
651
|
-
cachePath,
|
|
652
|
-
runtimePath,
|
|
653
|
-
skipped: true,
|
|
654
|
-
reason: "cache_account_mismatch",
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
if (cacheStat && sourceStat.mtimeMs <= cacheStat.mtimeMs) {
|
|
660
|
-
return createSyncResult({
|
|
661
|
-
accountId,
|
|
662
|
-
direction,
|
|
663
|
-
sourcePath,
|
|
664
|
-
cachePath,
|
|
665
|
-
runtimePath,
|
|
666
|
-
skipped: true,
|
|
667
|
-
reason: "up_to_date",
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
this.#copyAuthPayload(sourcePath, cachePath);
|
|
672
|
-
return createSyncResult({
|
|
673
|
-
accountId,
|
|
674
|
-
direction,
|
|
675
|
-
sourcePath,
|
|
676
|
-
cachePath,
|
|
677
|
-
runtimePath,
|
|
678
|
-
copied: true,
|
|
679
|
-
reason: cacheStat ? "cache_updated" : "cache_created",
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const cacheStat = statOrNull(cachePath);
|
|
684
|
-
if (!cacheStat) {
|
|
685
|
-
return createSyncResult({
|
|
686
|
-
accountId,
|
|
687
|
-
direction,
|
|
688
|
-
sourcePath,
|
|
689
|
-
cachePath,
|
|
690
|
-
runtimePath,
|
|
691
|
-
skipped: true,
|
|
692
|
-
reason: "cache_missing",
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
const cacheAuth = readAuthPayload(cachePath);
|
|
697
|
-
if (!cacheAuth.accountId) {
|
|
698
|
-
return createSyncResult({
|
|
699
|
-
accountId,
|
|
700
|
-
direction,
|
|
701
|
-
sourcePath,
|
|
702
|
-
cachePath,
|
|
703
|
-
runtimePath,
|
|
704
|
-
skipped: true,
|
|
705
|
-
reason: "cache_account_unknown",
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
if (cacheAuth.accountId !== accountId) {
|
|
709
|
-
return createSyncResult({
|
|
710
|
-
accountId,
|
|
711
|
-
direction,
|
|
712
|
-
sourcePath,
|
|
713
|
-
cachePath,
|
|
714
|
-
runtimePath,
|
|
715
|
-
skipped: true,
|
|
716
|
-
reason: "cache_account_mismatch",
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const sourceStat = statOrNull(sourcePath);
|
|
721
|
-
if (sourceStat && cacheStat.mtimeMs <= sourceStat.mtimeMs) {
|
|
722
|
-
return createSyncResult({
|
|
723
|
-
accountId,
|
|
724
|
-
direction,
|
|
725
|
-
sourcePath,
|
|
726
|
-
cachePath,
|
|
727
|
-
runtimePath,
|
|
728
|
-
skipped: true,
|
|
729
|
-
reason: "up_to_date",
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
this.#copyAuthPayload(cachePath, sourcePath);
|
|
734
|
-
return createSyncResult({
|
|
735
|
-
accountId,
|
|
736
|
-
direction,
|
|
737
|
-
sourcePath,
|
|
738
|
-
cachePath,
|
|
739
|
-
runtimePath,
|
|
740
|
-
copied: true,
|
|
741
|
-
reason: sourceStat ? "source_updated" : "source_created",
|
|
742
|
-
});
|
|
743
|
-
} catch (error) {
|
|
744
|
-
return createSyncResult({
|
|
745
|
-
accountId,
|
|
746
|
-
direction,
|
|
747
|
-
sourcePath,
|
|
748
|
-
cachePath,
|
|
749
|
-
runtimePath,
|
|
750
|
-
skipped: true,
|
|
751
|
-
reason: error?.code === "ENOENT" ? "file_missing" : "read_error",
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
},
|
|
755
|
-
);
|
|
756
|
-
|
|
757
|
-
if (locked?.lockTimeout) {
|
|
758
|
-
return createSyncResult({
|
|
759
|
-
accountId,
|
|
760
|
-
direction,
|
|
761
|
-
sourcePath,
|
|
762
|
-
cachePath,
|
|
763
|
-
runtimePath,
|
|
764
|
-
skipped: true,
|
|
765
|
-
reason: "lock_timeout",
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
return locked;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
syncAuthFromSource(accountId) {
|
|
773
|
-
return this.#syncAuth(accountId, "from-source");
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
syncAuthToSource(accountId) {
|
|
777
|
-
return this.#syncAuth(accountId, "to-source");
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
syncRuntimeAuthToSource(accountId) {
|
|
781
|
-
return this.#syncAuth(accountId, "runtime-to-source");
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// ── per-account circuit breaker ─────────────────────────────────
|
|
785
|
-
|
|
786
|
-
#getCircuitState(acct, now) {
|
|
787
|
-
const validFailures = acct.failureTimestamps.filter(
|
|
788
|
-
(ts) => now - ts < CIRCUIT_WINDOW_MS,
|
|
789
|
-
);
|
|
790
|
-
const withinWindow =
|
|
791
|
-
acct.circuitOpenedAt && now - acct.circuitOpenedAt < CIRCUIT_WINDOW_MS;
|
|
792
|
-
if (withinWindow) return { state: "open", failures: validFailures };
|
|
793
|
-
if (acct.circuitOpenedAt)
|
|
794
|
-
return { state: "half-open", failures: validFailures };
|
|
795
|
-
return { state: "closed", failures: validFailures };
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
#isCircuitBlocked(acct, now) {
|
|
799
|
-
const circuit = this.#getCircuitState(acct, now);
|
|
800
|
-
if (circuit.state === "open") return true;
|
|
801
|
-
if (circuit.state === "half-open" && acct.circuitTrialInFlight) return true;
|
|
802
|
-
return false;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
#recordCircuitFailure(acct, isHalfOpen, now) {
|
|
806
|
-
const validFailures = [
|
|
807
|
-
...acct.failureTimestamps.filter((ts) => now - ts < CIRCUIT_WINDOW_MS),
|
|
808
|
-
now,
|
|
809
|
-
];
|
|
810
|
-
const shouldOpen =
|
|
811
|
-
isHalfOpen || validFailures.length >= CIRCUIT_MAX_FAILURES;
|
|
812
|
-
return {
|
|
813
|
-
failureTimestamps: validFailures,
|
|
814
|
-
circuitOpenedAt: shouldOpen ? now : acct.circuitOpenedAt,
|
|
815
|
-
circuitTrialInFlight: false,
|
|
816
|
-
};
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
#resetCircuit() {
|
|
820
|
-
return {
|
|
821
|
-
failureTimestamps: [],
|
|
822
|
-
circuitOpenedAt: 0,
|
|
823
|
-
circuitTrialInFlight: false,
|
|
824
|
-
};
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// ── lease TTL pruning ──────────────────────────────────────────
|
|
828
|
-
|
|
829
|
-
#pruneExpiredLeases(now) {
|
|
830
|
-
for (const [id, acct] of this.#state) {
|
|
831
|
-
if (
|
|
832
|
-
acct.busy &&
|
|
833
|
-
acct.leasedAt !== null &&
|
|
834
|
-
now - acct.leasedAt > LEASE_TTL_MS
|
|
835
|
-
) {
|
|
836
|
-
this.#state.set(id, { ...acct, busy: false, leasedAt: null });
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// ── lease ─────────────────────────────────────────────────────
|
|
842
|
-
|
|
843
|
-
lease({ provider, remote = false, autoSync = true } = {}) {
|
|
844
|
-
const now = Date.now();
|
|
845
|
-
this.#pruneExpiredLeases(now);
|
|
846
|
-
|
|
847
|
-
const wantsRemote = remote === true;
|
|
848
|
-
const accounts = [...this.#state.values()].filter(
|
|
849
|
-
(a) => a.provider === provider && isRemoteAccount(a) === wantsRemote,
|
|
850
|
-
);
|
|
851
|
-
if (!accounts.length) return null;
|
|
852
|
-
|
|
853
|
-
// filter: not busy, not in cooldown, circuit not blocked
|
|
854
|
-
const available = accounts.filter(
|
|
855
|
-
(a) =>
|
|
856
|
-
!a.busy && a.cooldownUntil <= now && !this.#isCircuitBlocked(a, now),
|
|
857
|
-
);
|
|
858
|
-
|
|
859
|
-
if (!available.length) {
|
|
860
|
-
// check if any accounts exist but all are blocked by circuit
|
|
861
|
-
const circuitBlocked = accounts.filter(
|
|
862
|
-
(a) =>
|
|
863
|
-
!a.busy && a.cooldownUntil <= now && this.#isCircuitBlocked(a, now),
|
|
864
|
-
);
|
|
865
|
-
if (circuitBlocked.length) {
|
|
866
|
-
this.emit("noAvailableAccounts", {
|
|
867
|
-
provider,
|
|
868
|
-
count: circuitBlocked.length,
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
return null;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// sort by tier priority; stable sort preserves original order within same priority
|
|
875
|
-
const sorted = [...available].sort(
|
|
876
|
-
(a, b) => (TIER_PRIORITY[a.tier] ?? 2) - (TIER_PRIORITY[b.tier] ?? 2),
|
|
877
|
-
);
|
|
878
|
-
|
|
879
|
-
// pick the best tier, then apply round-robin within that tier's accounts
|
|
880
|
-
const bestTier = sorted[0].tier;
|
|
881
|
-
const sameTierAccounts = sorted.filter((a) => a.tier === bestTier);
|
|
882
|
-
|
|
883
|
-
// detect tier fallback
|
|
884
|
-
const highestTier = accounts.reduce(
|
|
885
|
-
(best, a) => Math.min(best, TIER_PRIORITY[a.tier] ?? 2),
|
|
886
|
-
Infinity,
|
|
887
|
-
);
|
|
888
|
-
if ((TIER_PRIORITY[bestTier] ?? 2) > highestTier) {
|
|
889
|
-
this.emit("tierFallback", {
|
|
890
|
-
provider,
|
|
891
|
-
from: Object.entries(TIER_PRIORITY).find(
|
|
892
|
-
([, v]) => v === highestTier,
|
|
893
|
-
)?.[0],
|
|
894
|
-
to: bestTier,
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// use a per-provider+tier round-robin key to distribute within the tier
|
|
899
|
-
const rrKey = `${provider}:${bestTier}:${wantsRemote ? "remote" : "local"}`;
|
|
900
|
-
const rrCurrent = this.#roundRobinIndex.get(rrKey) ?? 0;
|
|
901
|
-
const tierCount = sameTierAccounts.length;
|
|
902
|
-
const idx = rrCurrent % tierCount;
|
|
903
|
-
const acct = sameTierAccounts[idx];
|
|
904
|
-
|
|
905
|
-
// advance round-robin index for this tier
|
|
906
|
-
this.#roundRobinIndex.set(rrKey, (idx + 1) % tierCount);
|
|
907
|
-
|
|
908
|
-
let authFile;
|
|
909
|
-
if (acct.mode === "auth") {
|
|
910
|
-
const resolved = this.#resolveCacheAuthPath(acct);
|
|
911
|
-
if (!resolved) {
|
|
912
|
-
this.emit("securityViolation", {
|
|
913
|
-
id: acct.id,
|
|
914
|
-
authFile: acct.authFile,
|
|
915
|
-
});
|
|
916
|
-
return null;
|
|
917
|
-
}
|
|
918
|
-
authFile = resolved;
|
|
919
|
-
if (autoSync !== false) {
|
|
920
|
-
try {
|
|
921
|
-
const syncResult = this.syncAuthFromSource(acct.id);
|
|
922
|
-
this.emit("authSync", syncResult);
|
|
923
|
-
if (!syncResult?.ok) {
|
|
924
|
-
this.emit("authSyncError", {
|
|
925
|
-
accountId: acct.id,
|
|
926
|
-
direction: "from-source",
|
|
927
|
-
reason: syncResult?.reason || "sync_failed",
|
|
928
|
-
});
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
} catch (error) {
|
|
932
|
-
this.emit("authSyncError", {
|
|
933
|
-
accountId: acct.id,
|
|
934
|
-
direction: "from-source",
|
|
935
|
-
error: error?.message || String(error),
|
|
936
|
-
});
|
|
937
|
-
return null;
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
const runtimeAuth = this.#prepareRuntimeAuthFile(acct, authFile);
|
|
941
|
-
if (!runtimeAuth.ok) {
|
|
942
|
-
this.emit("authSyncError", {
|
|
943
|
-
accountId: acct.id,
|
|
944
|
-
direction: "runtime-auth",
|
|
945
|
-
reason: runtimeAuth.reason,
|
|
946
|
-
});
|
|
947
|
-
return null;
|
|
948
|
-
}
|
|
949
|
-
authFile = runtimeAuth.authFile;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// mark half-open trial if applicable
|
|
953
|
-
const circuit = this.#getCircuitState(acct, now);
|
|
954
|
-
const isHalfOpen = circuit.state === "half-open";
|
|
955
|
-
|
|
956
|
-
// update state (immutable)
|
|
957
|
-
this.#state.set(acct.id, {
|
|
958
|
-
...acct,
|
|
959
|
-
busy: true,
|
|
960
|
-
leasedAt: now,
|
|
961
|
-
lastUsedAt: now,
|
|
962
|
-
totalSessions: acct.totalSessions + 1,
|
|
963
|
-
circuitTrialInFlight: isHalfOpen ? true : acct.circuitTrialInFlight,
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
this.emit("lease", {
|
|
967
|
-
id: acct.id,
|
|
968
|
-
provider,
|
|
969
|
-
tier: acct.tier,
|
|
970
|
-
halfOpen: isHalfOpen,
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
return {
|
|
974
|
-
id: acct.id,
|
|
975
|
-
mode: acct.mode,
|
|
976
|
-
remote: isRemoteAccount(acct),
|
|
977
|
-
host: acct.host,
|
|
978
|
-
halfOpen: isHalfOpen,
|
|
979
|
-
profile: acct.mode === "profile" ? acct.profile : undefined,
|
|
980
|
-
env: acct.mode === "env" ? resolveEnvValues(acct.env) : undefined,
|
|
981
|
-
authFile,
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// ── release ───────────────────────────────────────────────────
|
|
986
|
-
|
|
987
|
-
release(accountId, result) {
|
|
988
|
-
const acct = this.#state.get(accountId);
|
|
989
|
-
if (!acct?.busy) return;
|
|
990
|
-
|
|
991
|
-
if (acct.provider === "codex" && acct.mode === "auth") {
|
|
992
|
-
const authSync = this.syncRuntimeAuthToSource(accountId);
|
|
993
|
-
this.emit(authSync?.ok ? "authSync" : "authSyncError", {
|
|
994
|
-
...authSync,
|
|
995
|
-
accountId,
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
const now = Date.now();
|
|
1000
|
-
const ok = result?.ok === true;
|
|
1001
|
-
const circuit = this.#getCircuitState(acct, now);
|
|
1002
|
-
const isHalfOpen = circuit.state === "half-open";
|
|
1003
|
-
|
|
1004
|
-
let circuitUpdate;
|
|
1005
|
-
if (ok) {
|
|
1006
|
-
circuitUpdate = this.#resetCircuit();
|
|
1007
|
-
if (isHalfOpen) {
|
|
1008
|
-
this.emit("circuitClose", { id: accountId });
|
|
1009
|
-
}
|
|
1010
|
-
} else if (result?.skipCircuit) {
|
|
1011
|
-
// 인프라 에러 — circuit/cooldown에 카운트하지 않음
|
|
1012
|
-
circuitUpdate = {
|
|
1013
|
-
failureTimestamps: acct.failureTimestamps,
|
|
1014
|
-
circuitOpenedAt: acct.circuitOpenedAt,
|
|
1015
|
-
circuitTrialInFlight: acct.circuitTrialInFlight,
|
|
1016
|
-
};
|
|
1017
|
-
} else {
|
|
1018
|
-
circuitUpdate = this.#recordCircuitFailure(acct, isHalfOpen, now);
|
|
1019
|
-
if (circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt) {
|
|
1020
|
-
this.emit("circuitOpen", {
|
|
1021
|
-
id: accountId,
|
|
1022
|
-
failures: circuitUpdate.failureTimestamps.length,
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
1028
|
-
|
|
1029
|
-
// rate-limit style cooldown: if circuit just opened, also set cooldown
|
|
1030
|
-
const shouldCooldown =
|
|
1031
|
-
!ok && circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt;
|
|
1032
|
-
|
|
1033
|
-
const updated = {
|
|
1034
|
-
...acct,
|
|
1035
|
-
busy: false,
|
|
1036
|
-
leasedAt: null,
|
|
1037
|
-
...circuitUpdate,
|
|
1038
|
-
cooldownUntil: shouldCooldown ? now + cooldownMs : acct.cooldownUntil,
|
|
1039
|
-
};
|
|
1040
|
-
|
|
1041
|
-
this.#state.set(accountId, updated);
|
|
1042
|
-
if (this.#persist) persistState(this.#state);
|
|
1043
|
-
this.emit("release", { id: accountId, ok });
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ── markRateLimited ───────────────────────────────────────────
|
|
1047
|
-
|
|
1048
|
-
markRateLimited(id, coolMs) {
|
|
1049
|
-
const acct = this.#state.get(id);
|
|
1050
|
-
if (!acct) return;
|
|
1051
|
-
this.#state.set(id, {
|
|
1052
|
-
...acct,
|
|
1053
|
-
busy: false,
|
|
1054
|
-
leasedAt: null,
|
|
1055
|
-
cooldownUntil: Date.now() + coolMs,
|
|
1056
|
-
});
|
|
1057
|
-
if (this.#persist) persistState(this.#state);
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// ── snapshot ──────────────────────────────────────────────────
|
|
1061
|
-
|
|
1062
|
-
snapshot() {
|
|
1063
|
-
const now = Date.now();
|
|
1064
|
-
this.#pruneExpiredLeases(now);
|
|
1065
|
-
return [...this.#state.values()].map((acct) => ({
|
|
1066
|
-
...acct,
|
|
1067
|
-
failureTimestamps: [...acct.failureTimestamps],
|
|
1068
|
-
remainingMs: getRemainingLeaseMs(acct, now),
|
|
1069
|
-
circuitState: this.#getCircuitState(acct, now).state,
|
|
1070
|
-
}));
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
publicSnapshot() {
|
|
1074
|
-
const now = Date.now();
|
|
1075
|
-
this.#pruneExpiredLeases(now);
|
|
1076
|
-
return [...this.#state.values()].map((acct) => ({
|
|
1077
|
-
id: acct.id,
|
|
1078
|
-
provider: acct.provider,
|
|
1079
|
-
tier: acct.tier ?? "unknown",
|
|
1080
|
-
busy: acct.busy === true,
|
|
1081
|
-
cooldownUntil: acct.cooldownUntil ?? 0,
|
|
1082
|
-
lastUsedAt: acct.lastUsedAt ?? 0,
|
|
1083
|
-
totalSessions: acct.totalSessions ?? 0,
|
|
1084
|
-
remainingMs: getRemainingLeaseMs(acct, now),
|
|
1085
|
-
circuitState: this.#getCircuitState(acct, now).state,
|
|
1086
|
-
failureCount: Array.isArray(acct.failureTimestamps)
|
|
1087
|
-
? acct.failureTimestamps.length
|
|
1088
|
-
: 0,
|
|
1089
|
-
}));
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
activeLeases() {
|
|
1093
|
-
const now = Date.now();
|
|
1094
|
-
this.#pruneExpiredLeases(now);
|
|
1095
|
-
return [...this.#state.values()]
|
|
1096
|
-
.filter((acct) => acct.busy && acct.leasedAt !== null)
|
|
1097
|
-
.map((acct) => ({
|
|
1098
|
-
id: acct.id,
|
|
1099
|
-
provider: acct.provider,
|
|
1100
|
-
busy: acct.busy,
|
|
1101
|
-
leasedAt: acct.leasedAt,
|
|
1102
|
-
lastUsedAt: acct.lastUsedAt,
|
|
1103
|
-
totalSessions: acct.totalSessions,
|
|
1104
|
-
circuitTrialInFlight: acct.circuitTrialInFlight,
|
|
1105
|
-
}));
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
// ── disabled marker ───────────────────────────────────────────
|
|
1109
|
-
|
|
1110
|
-
/**
|
|
1111
|
-
* True when the broker holds zero accounts. Used by adapters to distinguish
|
|
1112
|
-
* "broker exists but is empty (disable env / no accounts)" from "broker has
|
|
1113
|
-
* accounts but lease() returned null (all busy/cooldown/circuit)" — the
|
|
1114
|
-
* former should fall back to default ~/.codex/auth.json instead of returning
|
|
1115
|
-
* circuit_open.
|
|
1116
|
-
*/
|
|
1117
|
-
get isDisabled() {
|
|
1118
|
-
return this.#state.size === 0;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// ── nextAvailableEta ──────────────────────────────────────────
|
|
1122
|
-
|
|
1123
|
-
nextAvailableEta(provider) {
|
|
1124
|
-
const now = Date.now();
|
|
1125
|
-
this.#pruneExpiredLeases(now);
|
|
1126
|
-
|
|
1127
|
-
const accounts = [...this.#state.values()].filter(
|
|
1128
|
-
(a) => a.provider === provider,
|
|
1129
|
-
);
|
|
1130
|
-
if (!accounts.length) return null;
|
|
1131
|
-
|
|
1132
|
-
// find minimum cooldownUntil among accounts that are in cooldown or busy
|
|
1133
|
-
let earliest = null;
|
|
1134
|
-
for (const acct of accounts) {
|
|
1135
|
-
if (!acct.busy && acct.cooldownUntil <= now) {
|
|
1136
|
-
// this account is available now — no ETA needed
|
|
1137
|
-
return null;
|
|
1138
|
-
}
|
|
1139
|
-
const eta = acct.busy
|
|
1140
|
-
? (acct.leasedAt ?? now) + LEASE_TTL_MS
|
|
1141
|
-
: acct.cooldownUntil;
|
|
1142
|
-
if (earliest === null || eta < earliest) {
|
|
1143
|
-
earliest = eta;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
return earliest;
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// ── Config loader ────────────────────────────────────────────────
|
|
1151
|
-
|
|
1152
|
-
function loadConfig() {
|
|
1153
|
-
const configPath = join(
|
|
1154
|
-
homedir(),
|
|
1155
|
-
".claude",
|
|
1156
|
-
"cache",
|
|
1157
|
-
"tfx-hub",
|
|
1158
|
-
"accounts.json",
|
|
1159
|
-
);
|
|
1160
|
-
if (!existsSync(configPath)) return null;
|
|
1161
|
-
try {
|
|
1162
|
-
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
1163
|
-
} catch (err) {
|
|
1164
|
-
console.error(
|
|
1165
|
-
"[account-broker] Failed to parse accounts.json:",
|
|
1166
|
-
err.message,
|
|
1167
|
-
);
|
|
1168
|
-
return null;
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// ── Singleton ────────────────────────────────────────────────────
|
|
1173
|
-
|
|
1174
|
-
function isBrokerDisabledByEnv() {
|
|
1175
|
-
const flag = process.env.TFX_DISABLE_ACCOUNT_BROKER;
|
|
1176
|
-
return flag === "1" || flag === "true";
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function createEmptyBroker() {
|
|
1180
|
-
return new AccountBroker({ codex: [], gemini: [] });
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
function createBroker() {
|
|
1184
|
-
if (isBrokerDisabledByEnv()) {
|
|
1185
|
-
// Empty broker: lease() always returns null → callers fall back to default
|
|
1186
|
-
// single-account path (~/.codex/auth.json). Avoids per-account multiplexing
|
|
1187
|
-
// race conditions while keeping the AccountBroker API surface intact.
|
|
1188
|
-
return createEmptyBroker();
|
|
1189
|
-
}
|
|
1190
|
-
const config = loadConfig();
|
|
1191
|
-
if (!config) return null;
|
|
1192
|
-
try {
|
|
1193
|
-
return new AccountBroker(config);
|
|
1194
|
-
} catch (err) {
|
|
1195
|
-
console.error("[account-broker] Failed to create broker:", err.message);
|
|
1196
|
-
return null;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
/** Re-read config and replace the module-level singleton. ESM live binding propagates to all importers. */
|
|
1201
|
-
function reloadBroker() {
|
|
1202
|
-
const activeLeases =
|
|
1203
|
-
broker && typeof broker.activeLeases === "function"
|
|
1204
|
-
? broker.activeLeases()
|
|
1205
|
-
: [];
|
|
1206
|
-
if (isBrokerDisabledByEnv()) {
|
|
1207
|
-
broker = createEmptyBroker();
|
|
1208
|
-
return { ok: true, broker, disabled: true };
|
|
1209
|
-
}
|
|
1210
|
-
const config = loadConfig();
|
|
1211
|
-
if (!config) return { ok: false, error: "Config not found or invalid" };
|
|
1212
|
-
try {
|
|
1213
|
-
broker = new AccountBroker(config, { _activeLeases: activeLeases });
|
|
1214
|
-
return { ok: true, broker };
|
|
1215
|
-
} catch (err) {
|
|
1216
|
-
return { ok: false, error: err.message };
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
export let broker = createBroker();
|
|
1221
|
-
export { AccountBroker, reloadBroker };
|