@triflux/core 10.1.0 → 10.26.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/hooks/agent-route-guard.mjs +14 -10
- package/hooks/cross-review-tracker.mjs +21 -8
- package/hooks/error-context.mjs +20 -8
- package/hooks/hook-adaptive-collector.mjs +18 -16
- package/hooks/hook-manager.mjs +93 -32
- package/hooks/hook-orchestrator.mjs +267 -35
- package/hooks/hook-registry.json +343 -256
- package/hooks/hooks.json +49 -1
- package/hooks/keyword-rules.json +191 -31
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hooks/mcp-config-watcher.mjs +6 -2
- package/hooks/permission-safe-allow.mjs +233 -0
- package/hooks/pipeline-stop.mjs +369 -22
- package/hooks/post-tool-tips.mjs +306 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hooks/safety-guard.mjs +262 -34
- package/hooks/session-end-cleanup.mjs +345 -0
- package/hooks/session-start-fast.mjs +214 -0
- package/hooks/subagent-tracker.mjs +267 -0
- package/hooks/subagent-verifier.mjs +9 -4
- package/hub/account-broker.mjs +1036 -66
- package/hub/adaptive-diagnostic.mjs +75 -48
- package/hub/adaptive-inject.mjs +96 -58
- package/hub/adaptive-memory.mjs +156 -42
- package/hub/adaptive.mjs +60 -31
- package/hub/assign-callbacks.mjs +163 -126
- package/hub/bridge.mjs +1051 -290
- package/hub/cli-adapter-base.mjs +321 -97
- package/hub/codex-adapter.mjs +179 -93
- package/hub/codex-compat.mjs +4 -4
- package/hub/codex-preflight.mjs +113 -37
- package/hub/delegator/contracts.mjs +22 -21
- package/hub/delegator/index.mjs +14 -14
- package/hub/delegator/schema/delegator-tools.schema.json +1 -1
- package/hub/delegator/service.mjs +93 -64
- package/hub/delegator/tool-definitions.mjs +35 -35
- package/hub/dynamic-routing-engine.mjs +511 -0
- package/hub/fullcycle.mjs +34 -18
- package/hub/gemini-adapter.mjs +82 -108
- package/hub/hitl.mjs +202 -143
- package/hub/intent.mjs +167 -38
- package/hub/lib/bash-path.mjs +73 -0
- package/hub/lib/cache-guard.mjs +43 -17
- package/hub/lib/env-detect.mjs +145 -0
- package/hub/lib/hosts-compat.mjs +319 -0
- package/hub/lib/mcp-response-cache.mjs +239 -0
- package/hub/lib/memory-store.mjs +285 -111
- package/hub/lib/path-utils.mjs +165 -0
- package/hub/lib/phase-manager.mjs +493 -0
- package/hub/lib/process-utils.mjs +797 -104
- package/hub/lib/prompt-tmp.mjs +33 -0
- package/hub/lib/risk-tier.mjs +53 -0
- package/hub/lib/spawn-trace.mjs +561 -0
- package/hub/lib/ssh-command.mjs +44 -24
- package/hub/lib/ssh-retry.mjs +5 -1
- package/hub/lib/staged-file-detect.mjs +43 -0
- package/hub/lib/state-snapshot.mjs +293 -0
- package/hub/lib/tfx-route-args.mjs +309 -0
- package/hub/lib/timeline-adapter.mjs +190 -0
- package/hub/lib/trace-recorder.mjs +153 -0
- package/hub/lib/uuidv7.mjs +4 -3
- package/hub/memory-doctor.mjs +266 -106
- package/hub/middleware/quota-middleware.mjs +70 -0
- package/hub/middleware/request-logger.mjs +61 -34
- package/hub/paths.mjs +9 -9
- package/hub/pipeline/gates/confidence.mjs +34 -15
- package/hub/pipeline/gates/consensus.mjs +27 -15
- package/hub/pipeline/gates/index.mjs +7 -3
- package/hub/pipeline/gates/selfcheck.mjs +57 -19
- package/hub/pipeline/index.mjs +77 -42
- package/hub/pipeline/state.mjs +10 -10
- package/hub/pipeline/transitions.mjs +40 -23
- package/hub/platform.mjs +65 -48
- package/hub/quality/deslop.mjs +70 -49
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +33 -25
- package/hub/router.mjs +941 -791
- package/hub/routing/complexity.mjs +132 -29
- package/hub/routing/index.mjs +17 -12
- package/hub/routing/q-learning.mjs +78 -30
- package/hub/routing-snapshot.mjs +261 -0
- package/hub/session-fingerprint.mjs +127 -61
- package/hub/state.mjs +86 -44
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/retry-state-machine.mjs +336 -0
- package/hub/token-mode.mjs +72 -71
- package/hub/workers/worker-utils.mjs +23 -17
- package/hud/colors.mjs +27 -9
- package/hud/constants.mjs +190 -27
- package/hud/context-monitor.mjs +133 -44
- package/hud/hud-qos-status.mjs +157 -50
- package/hud/mission-board.mjs +56 -0
- package/hud/providers/claude.mjs +398 -114
- package/hud/providers/codex.mjs +96 -21
- package/hud/providers/gemini.mjs +210 -52
- package/hud/renderers.mjs +440 -127
- package/hud/terminal.mjs +101 -31
- package/hud/utils.mjs +81 -39
- package/mesh/index.mjs +69 -0
- package/mesh/mesh-budget.mjs +137 -0
- package/mesh/mesh-heartbeat.mjs +100 -0
- package/mesh/mesh-protocol.mjs +96 -0
- package/mesh/mesh-queue.mjs +163 -0
- package/mesh/mesh-registry.mjs +78 -0
- package/mesh/mesh-router.mjs +77 -0
- package/package.json +5 -1
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +6 -1
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +37 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +3 -3
- package/scripts/lib/cross-review-utils.mjs +13 -5
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +58 -30
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +44 -10
- package/scripts/lib/handoff.mjs +33 -17
- package/scripts/lib/hook-utils.mjs +8 -6
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +49 -20
- package/scripts/lib/logger.mjs +24 -24
- package/scripts/lib/mcp-filter.mjs +391 -247
- package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
- package/scripts/lib/mcp-guard-engine.mjs +1281 -115
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +24 -14
- package/scripts/lib/mcp-server-catalog.mjs +355 -118
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +117 -12
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +44 -14
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +32 -7
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
package/hub/account-broker.mjs
CHANGED
|
@@ -1,43 +1,131 @@
|
|
|
1
1
|
// hub/account-broker.mjs — Multi-account CLI pool broker
|
|
2
|
-
// Manages lease/release/cooldown for Codex and Gemini accounts.
|
|
2
|
+
// Manages lease/release/cooldown/circuit-breaker for Codex and Gemini accounts.
|
|
3
|
+
// Per-account circuit breaker: one bad account does not block others.
|
|
3
4
|
// Singleton export. All state changes create new objects (immutable pattern).
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
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";
|
|
9
25
|
|
|
10
26
|
// ── Zod schema ───────────────────────────────────────────────────
|
|
11
27
|
|
|
12
|
-
const AccountSchema = z
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
});
|
|
28
50
|
|
|
29
51
|
const ConfigSchema = z.object({
|
|
30
|
-
defaults: z
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
defaults: z
|
|
53
|
+
.object({
|
|
54
|
+
cooldownMs: z.number().int().positive().optional(),
|
|
55
|
+
})
|
|
56
|
+
.optional(),
|
|
33
57
|
codex: z.array(AccountSchema).optional(),
|
|
34
58
|
gemini: z.array(AccountSchema).optional(),
|
|
35
59
|
});
|
|
36
60
|
|
|
37
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
|
+
};
|
|
38
67
|
const TIER_PRIORITY = { pro: 0, plus: 1, unknown: 2, free: 3 };
|
|
39
68
|
const LEASE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
40
|
-
const
|
|
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
|
+
}
|
|
41
129
|
|
|
42
130
|
// ── env var resolution ───────────────────────────────────────────
|
|
43
131
|
|
|
@@ -45,9 +133,9 @@ function resolveEnvValues(env) {
|
|
|
45
133
|
if (!env) return undefined;
|
|
46
134
|
const resolved = {};
|
|
47
135
|
for (const [key, value] of Object.entries(env)) {
|
|
48
|
-
if (typeof value ===
|
|
136
|
+
if (typeof value === "string" && value.startsWith("$")) {
|
|
49
137
|
const varName = value.slice(1);
|
|
50
|
-
resolved[key] = process.env[varName] ??
|
|
138
|
+
resolved[key] = process.env[varName] ?? "";
|
|
51
139
|
} else {
|
|
52
140
|
resolved[key] = value;
|
|
53
141
|
}
|
|
@@ -55,26 +143,188 @@ function resolveEnvValues(env) {
|
|
|
55
143
|
return resolved;
|
|
56
144
|
}
|
|
57
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
|
+
|
|
58
255
|
// ── AccountBroker ────────────────────────────────────────────────
|
|
59
256
|
|
|
60
|
-
|
|
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 {
|
|
61
271
|
#config;
|
|
62
272
|
#state; // Map<accountId, accountState>
|
|
63
273
|
#roundRobinIndex; // Map<provider, number>
|
|
274
|
+
#persist; // boolean — disable persistence for tests
|
|
275
|
+
#authBasePath;
|
|
276
|
+
#codexAuthSourcePath;
|
|
277
|
+
#authSyncLockOpts;
|
|
278
|
+
#syncCopyDelayMs;
|
|
64
279
|
|
|
65
|
-
constructor(
|
|
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();
|
|
66
294
|
const parsed = ConfigSchema.parse(config);
|
|
67
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;
|
|
68
305
|
|
|
69
306
|
this.#state = new Map();
|
|
70
307
|
this.#roundRobinIndex = new Map();
|
|
71
308
|
|
|
72
309
|
const allAccounts = [
|
|
73
|
-
...(parsed.codex || []).map((a) => ({ ...a, provider:
|
|
74
|
-
...(parsed.gemini || []).map((a) => ({ ...a, provider:
|
|
310
|
+
...(parsed.codex || []).map((a) => ({ ...a, provider: "codex" })),
|
|
311
|
+
...(parsed.gemini || []).map((a) => ({ ...a, provider: "gemini" })),
|
|
75
312
|
];
|
|
76
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
|
+
|
|
77
325
|
for (const account of allAccounts) {
|
|
326
|
+
const saved = pEntries[account.id];
|
|
327
|
+
const activeLease = activeLeaseById.get(account.id);
|
|
78
328
|
this.#state.set(account.id, {
|
|
79
329
|
id: account.id,
|
|
80
330
|
provider: account.provider,
|
|
@@ -82,22 +332,507 @@ class AccountBroker {
|
|
|
82
332
|
profile: account.profile,
|
|
83
333
|
env: account.env,
|
|
84
334
|
authFile: account.authFile,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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",
|
|
92
766
|
});
|
|
93
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
|
+
};
|
|
94
825
|
}
|
|
95
826
|
|
|
96
827
|
// ── lease TTL pruning ──────────────────────────────────────────
|
|
97
828
|
|
|
98
829
|
#pruneExpiredLeases(now) {
|
|
99
830
|
for (const [id, acct] of this.#state) {
|
|
100
|
-
if (
|
|
831
|
+
if (
|
|
832
|
+
acct.busy &&
|
|
833
|
+
acct.leasedAt !== null &&
|
|
834
|
+
now - acct.leasedAt > LEASE_TTL_MS
|
|
835
|
+
) {
|
|
101
836
|
this.#state.set(id, { ...acct, busy: false, leasedAt: null });
|
|
102
837
|
}
|
|
103
838
|
}
|
|
@@ -105,16 +840,36 @@ class AccountBroker {
|
|
|
105
840
|
|
|
106
841
|
// ── lease ─────────────────────────────────────────────────────
|
|
107
842
|
|
|
108
|
-
lease({ provider }) {
|
|
843
|
+
lease({ provider, remote = false, autoSync = true } = {}) {
|
|
109
844
|
const now = Date.now();
|
|
110
845
|
this.#pruneExpiredLeases(now);
|
|
111
846
|
|
|
112
|
-
const
|
|
847
|
+
const wantsRemote = remote === true;
|
|
848
|
+
const accounts = [...this.#state.values()].filter(
|
|
849
|
+
(a) => a.provider === provider && isRemoteAccount(a) === wantsRemote,
|
|
850
|
+
);
|
|
113
851
|
if (!accounts.length) return null;
|
|
114
852
|
|
|
115
|
-
//
|
|
116
|
-
const available = accounts.filter(
|
|
117
|
-
|
|
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
|
+
}
|
|
118
873
|
|
|
119
874
|
// sort by tier priority; stable sort preserves original order within same priority
|
|
120
875
|
const sorted = [...available].sort(
|
|
@@ -125,8 +880,23 @@ class AccountBroker {
|
|
|
125
880
|
const bestTier = sorted[0].tier;
|
|
126
881
|
const sameTierAccounts = sorted.filter((a) => a.tier === bestTier);
|
|
127
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
|
+
|
|
128
898
|
// use a per-provider+tier round-robin key to distribute within the tier
|
|
129
|
-
const rrKey = `${provider}:${bestTier}`;
|
|
899
|
+
const rrKey = `${provider}:${bestTier}:${wantsRemote ? "remote" : "local"}`;
|
|
130
900
|
const rrCurrent = this.#roundRobinIndex.get(rrKey) ?? 0;
|
|
131
901
|
const tierCount = sameTierAccounts.length;
|
|
132
902
|
const idx = rrCurrent % tierCount;
|
|
@@ -135,6 +905,54 @@ class AccountBroker {
|
|
|
135
905
|
// advance round-robin index for this tier
|
|
136
906
|
this.#roundRobinIndex.set(rrKey, (idx + 1) % tierCount);
|
|
137
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
|
+
|
|
138
956
|
// update state (immutable)
|
|
139
957
|
this.#state.set(acct.id, {
|
|
140
958
|
...acct,
|
|
@@ -142,14 +960,25 @@ class AccountBroker {
|
|
|
142
960
|
leasedAt: now,
|
|
143
961
|
lastUsedAt: now,
|
|
144
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,
|
|
145
971
|
});
|
|
146
972
|
|
|
147
973
|
return {
|
|
148
974
|
id: acct.id,
|
|
149
975
|
mode: acct.mode,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
153
982
|
};
|
|
154
983
|
}
|
|
155
984
|
|
|
@@ -157,26 +986,61 @@ class AccountBroker {
|
|
|
157
986
|
|
|
158
987
|
release(accountId, result) {
|
|
159
988
|
const acct = this.#state.get(accountId);
|
|
160
|
-
if (!acct) return;
|
|
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
|
+
}
|
|
161
998
|
|
|
999
|
+
const now = Date.now();
|
|
162
1000
|
const ok = result?.ok === true;
|
|
163
|
-
const
|
|
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
|
+
|
|
164
1027
|
const cooldownMs = this.#config.defaults?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
165
1028
|
|
|
1029
|
+
// rate-limit style cooldown: if circuit just opened, also set cooldown
|
|
1030
|
+
const shouldCooldown =
|
|
1031
|
+
!ok && circuitUpdate.circuitOpenedAt !== acct.circuitOpenedAt;
|
|
1032
|
+
|
|
166
1033
|
const updated = {
|
|
167
1034
|
...acct,
|
|
168
1035
|
busy: false,
|
|
169
1036
|
leasedAt: null,
|
|
170
|
-
|
|
1037
|
+
...circuitUpdate,
|
|
1038
|
+
cooldownUntil: shouldCooldown ? now + cooldownMs : acct.cooldownUntil,
|
|
171
1039
|
};
|
|
172
1040
|
|
|
173
|
-
// consecutive failure guard: 3+ failures → auto-cooldown
|
|
174
|
-
if (newFailures >= 3) {
|
|
175
|
-
updated.cooldownUntil = Date.now() + cooldownMs;
|
|
176
|
-
updated.failures = 0; // reset after cooldown triggered
|
|
177
|
-
}
|
|
178
|
-
|
|
179
1041
|
this.#state.set(accountId, updated);
|
|
1042
|
+
if (this.#persist) persistState(this.#state);
|
|
1043
|
+
this.emit("release", { id: accountId, ok });
|
|
180
1044
|
}
|
|
181
1045
|
|
|
182
1046
|
// ── markRateLimited ───────────────────────────────────────────
|
|
@@ -190,12 +1054,68 @@ class AccountBroker {
|
|
|
190
1054
|
leasedAt: null,
|
|
191
1055
|
cooldownUntil: Date.now() + coolMs,
|
|
192
1056
|
});
|
|
1057
|
+
if (this.#persist) persistState(this.#state);
|
|
193
1058
|
}
|
|
194
1059
|
|
|
195
1060
|
// ── snapshot ──────────────────────────────────────────────────
|
|
196
1061
|
|
|
197
1062
|
snapshot() {
|
|
198
|
-
|
|
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;
|
|
199
1119
|
}
|
|
200
1120
|
|
|
201
1121
|
// ── nextAvailableEta ──────────────────────────────────────────
|
|
@@ -204,7 +1124,9 @@ class AccountBroker {
|
|
|
204
1124
|
const now = Date.now();
|
|
205
1125
|
this.#pruneExpiredLeases(now);
|
|
206
1126
|
|
|
207
|
-
const accounts = [...this.#state.values()].filter(
|
|
1127
|
+
const accounts = [...this.#state.values()].filter(
|
|
1128
|
+
(a) => a.provider === provider,
|
|
1129
|
+
);
|
|
208
1130
|
if (!accounts.length) return null;
|
|
209
1131
|
|
|
210
1132
|
// find minimum cooldownUntil among accounts that are in cooldown or busy
|
|
@@ -214,7 +1136,9 @@ class AccountBroker {
|
|
|
214
1136
|
// this account is available now — no ETA needed
|
|
215
1137
|
return null;
|
|
216
1138
|
}
|
|
217
|
-
const eta = acct.busy
|
|
1139
|
+
const eta = acct.busy
|
|
1140
|
+
? (acct.leasedAt ?? now) + LEASE_TTL_MS
|
|
1141
|
+
: acct.cooldownUntil;
|
|
218
1142
|
if (earliest === null || eta < earliest) {
|
|
219
1143
|
earliest = eta;
|
|
220
1144
|
}
|
|
@@ -226,26 +1150,72 @@ class AccountBroker {
|
|
|
226
1150
|
// ── Config loader ────────────────────────────────────────────────
|
|
227
1151
|
|
|
228
1152
|
function loadConfig() {
|
|
229
|
-
const configPath = join(
|
|
1153
|
+
const configPath = join(
|
|
1154
|
+
homedir(),
|
|
1155
|
+
".claude",
|
|
1156
|
+
"cache",
|
|
1157
|
+
"tfx-hub",
|
|
1158
|
+
"accounts.json",
|
|
1159
|
+
);
|
|
230
1160
|
if (!existsSync(configPath)) return null;
|
|
231
1161
|
try {
|
|
232
|
-
return JSON.parse(readFileSync(configPath,
|
|
233
|
-
} catch {
|
|
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
|
+
);
|
|
234
1168
|
return null;
|
|
235
1169
|
}
|
|
236
1170
|
}
|
|
237
1171
|
|
|
238
1172
|
// ── Singleton ────────────────────────────────────────────────────
|
|
239
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
|
+
|
|
240
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
|
+
}
|
|
241
1190
|
const config = loadConfig();
|
|
242
1191
|
if (!config) return null;
|
|
243
1192
|
try {
|
|
244
1193
|
return new AccountBroker(config);
|
|
245
|
-
} catch {
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
console.error("[account-broker] Failed to create broker:", err.message);
|
|
246
1196
|
return null;
|
|
247
1197
|
}
|
|
248
1198
|
}
|
|
249
1199
|
|
|
250
|
-
|
|
251
|
-
|
|
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 };
|