codex-multi-auth 2.20.0 → 2.22.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/.codex-plugin/plugin.json +1 -1
- package/README.md +5 -4
- package/dist/lib/accounts.d.ts +5 -0
- package/dist/lib/accounts.js +33 -4
- package/dist/lib/api-route-store.d.ts +6 -0
- package/dist/lib/api-route-store.js +98 -30
- package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
- package/dist/lib/codex-manager/api-login-menu.js +4 -6
- package/dist/lib/codex-manager/commands/status.d.ts +1 -1
- package/dist/lib/codex-manager/commands/status.js +26 -9
- package/dist/lib/codex-manager.js +7 -1
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/model-route-policy.d.ts +1 -0
- package/dist/lib/model-route-policy.js +2 -1
- package/dist/lib/runtime/account-model-catalog.d.ts +1 -0
- package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
- package/dist/lib/runtime/api-model-capabilities.js +155 -24
- package/dist/lib/runtime/app-bind.d.ts +4 -0
- package/dist/lib/runtime/app-bind.js +32 -1
- package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
- package/dist/lib/runtime/automatic-api-checks.js +188 -0
- package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
- package/dist/lib/runtime/model-discovery-status.js +64 -49
- package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
- package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
- package/dist/lib/runtime/responses-websocket.d.ts +18 -5
- package/dist/lib/runtime/responses-websocket.js +33 -11
- package/dist/lib/runtime/rotation-account-selection.d.ts +20 -1
- package/dist/lib/runtime/rotation-account-selection.js +94 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
- package/dist/lib/runtime/rotation-proxy-state.js +376 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +3 -0
- package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
- package/dist/lib/runtime/rotation-token-refresh.js +218 -27
- package/dist/lib/runtime-rotation-proxy.js +571 -96
- package/dist/lib/storage/credential-sidecars.js +34 -2
- package/dist/lib/storage/file-lock.d.ts +2 -0
- package/dist/lib/storage/file-lock.js +44 -30
- package/dist/lib/usage/ledger.js +1 -0
- package/dist/lib/usage/redaction.js +1 -0
- package/dist/lib/usage/types.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/codex-app-router.js +42 -14
|
@@ -1,6 +1,61 @@
|
|
|
1
|
-
import { AccountManager } from "../accounts.js";
|
|
1
|
+
import { AccountManager, sanitizeEmail } from "../accounts.js";
|
|
2
2
|
import { recordRuntimeReload, recordRuntimeReset, } from "./runtime-observability.js";
|
|
3
|
+
import { redirectRuntimeRefreshCommits, waitForRuntimeRefreshCommits } from "./rotation-token-refresh.js";
|
|
3
4
|
const STALE_RUNTIME_RELOAD_DEDUPE_MS = 1_000;
|
|
5
|
+
/**
|
|
6
|
+
* Copy credentials that rotated on `previousManager` since `authBeforeLoad`
|
|
7
|
+
* into the matching rows of `reloaded`. A replacement snapshot read before a
|
|
8
|
+
* refresh commit persisted still holds the spent single-use refresh token;
|
|
9
|
+
* without this carry the swap reintroduces it and the next refresh fails with
|
|
10
|
+
* token reuse. Matching is deliberately conservative — persisted record id,
|
|
11
|
+
* then the refresh token on either side of the rotation, then accountId+email —
|
|
12
|
+
* so a credential never lands on a different account.
|
|
13
|
+
*/
|
|
14
|
+
function carryRotatedCredentials(previousManager, authBeforeLoad, reloaded) {
|
|
15
|
+
const candidates = reloaded.getAccountsSnapshot();
|
|
16
|
+
for (const previous of previousManager.getAccountsSnapshot()) {
|
|
17
|
+
const baseline = authBeforeLoad.find((account) => (account.recordId && account.recordId === previous.recordId) ||
|
|
18
|
+
account.index === previous.index);
|
|
19
|
+
if (!baseline ||
|
|
20
|
+
(baseline.access === previous.access &&
|
|
21
|
+
baseline.refreshToken === previous.refreshToken &&
|
|
22
|
+
baseline.expires === previous.expires)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const rotatedTokens = new Set([previous.refreshToken, baseline.refreshToken]);
|
|
26
|
+
const sourceEmail = sanitizeEmail(previous.email);
|
|
27
|
+
const matches = candidates.filter((candidate) => (previousManager.hasPersistedRecordId(previous) &&
|
|
28
|
+
candidate.recordId === previous.recordId) ||
|
|
29
|
+
rotatedTokens.has(candidate.refreshToken) ||
|
|
30
|
+
(previous.accountId !== undefined &&
|
|
31
|
+
candidate.accountId === previous.accountId &&
|
|
32
|
+
sanitizeEmail(candidate.email) === sourceEmail));
|
|
33
|
+
const [matched] = matches;
|
|
34
|
+
if (matches.length !== 1 || !matched)
|
|
35
|
+
continue;
|
|
36
|
+
const live = reloaded.getAccountByIndex(matched.index);
|
|
37
|
+
if (!live)
|
|
38
|
+
continue;
|
|
39
|
+
live.access = previous.access;
|
|
40
|
+
live.refreshToken = previous.refreshToken;
|
|
41
|
+
live.expires = previous.expires;
|
|
42
|
+
// Token-bound identity (accountId/accountIdSource/email) rides along with
|
|
43
|
+
// the rotated credential — `commitRefreshedAuth` can refresh it during the
|
|
44
|
+
// same window, and without it the row keeps a workspace binding issued for
|
|
45
|
+
// the spent token. Explicit manual/org bindings stay authoritative.
|
|
46
|
+
const tokenBoundIdentityChanged = baseline.accountId !== previous.accountId ||
|
|
47
|
+
baseline.accountIdSource !== previous.accountIdSource;
|
|
48
|
+
const liveHasExplicitBinding = live.accountIdSource === "manual" || live.accountIdSource === "org";
|
|
49
|
+
const previousHasTokenBinding = previous.accountIdSource === "token" || previous.accountIdSource === "id_token";
|
|
50
|
+
if (tokenBoundIdentityChanged && previousHasTokenBinding && !liveHasExplicitBinding) {
|
|
51
|
+
live.accountId = previous.accountId;
|
|
52
|
+
live.accountIdSource = previous.accountIdSource;
|
|
53
|
+
}
|
|
54
|
+
if (baseline.email !== previous.email) {
|
|
55
|
+
live.email = previous.email;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
4
59
|
/** @internal */
|
|
5
60
|
export function createRotationProxyState(init) {
|
|
6
61
|
return {
|
|
@@ -24,21 +79,325 @@ export function createRotationProxyState(init) {
|
|
|
24
79
|
lastGlobalSwitchAt: 0,
|
|
25
80
|
staleRuntimeReloadPromise: null,
|
|
26
81
|
lastStaleRuntimeReloadAt: 0,
|
|
82
|
+
credentialReloadPromise: null,
|
|
83
|
+
lastCredentialReloadAt: 0,
|
|
84
|
+
transcriptionNextAccountIndex: 0,
|
|
85
|
+
transcriptionRateLimitUntilByRecordId: new Map(),
|
|
27
86
|
};
|
|
28
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Reload account credentials without resetting inference runtime state.
|
|
90
|
+
*
|
|
91
|
+
* This is intentionally narrower than {@link recoverStaleRuntimeState}: it is
|
|
92
|
+
* used when a non-inference operation discovers that its in-memory credential
|
|
93
|
+
* inventory is stale after an external login/enable action. Fresh credential,
|
|
94
|
+
* workspace, enabled and auth-invalidation fields come from disk, while live
|
|
95
|
+
* inference rate-limit windows and non-auth cooldowns are carried forward by
|
|
96
|
+
* stable account identity. An auth-failure cooldown is carried only while the
|
|
97
|
+
* access token is unchanged, so a genuine re-login can repair the credential.
|
|
98
|
+
* Process-global health/token trackers and circuit breakers are never reset.
|
|
99
|
+
*
|
|
100
|
+
* @internal
|
|
101
|
+
*/
|
|
102
|
+
export async function reloadRuntimeCredentials(state) {
|
|
103
|
+
// Inference stale recovery is destructive by design and takes precedence over
|
|
104
|
+
// the credential-only refresh. Sharing the in-flight result prevents two
|
|
105
|
+
// asynchronous loaders from racing to replace activeAccountManager.
|
|
106
|
+
if (state.staleRuntimeReloadPromise)
|
|
107
|
+
return state.staleRuntimeReloadPromise;
|
|
108
|
+
if (Date.now() - state.lastCredentialReloadAt <= STALE_RUNTIME_RELOAD_DEDUPE_MS) {
|
|
109
|
+
return state.activeAccountManager;
|
|
110
|
+
}
|
|
111
|
+
if (state.credentialReloadPromise)
|
|
112
|
+
return state.credentialReloadPromise;
|
|
113
|
+
state.credentialReloadPromise = (async () => {
|
|
114
|
+
const previousManager = state.activeAccountManager;
|
|
115
|
+
/** Stable matching key for carrying only runtime observations between credential snapshots. */
|
|
116
|
+
const identityOf = (account) => {
|
|
117
|
+
const accountId = account.accountId?.trim();
|
|
118
|
+
const email = sanitizeEmail(account.email);
|
|
119
|
+
return accountId || email
|
|
120
|
+
? JSON.stringify([accountId ?? null, email ?? null])
|
|
121
|
+
: account.refreshToken?.trim()
|
|
122
|
+
? `refresh:${account.refreshToken.trim()}`
|
|
123
|
+
: null;
|
|
124
|
+
};
|
|
125
|
+
// Capture auth before I/O so a token rotation that lands while loadFromDisk
|
|
126
|
+
// is suspended can be distinguished from credentials intentionally changed
|
|
127
|
+
// on disk by another login. Only the former is carried into the replacement.
|
|
128
|
+
const authBeforeLoad = previousManager.getAccountsSnapshot();
|
|
129
|
+
const reloaded = await AccountManager.loadFromDisk();
|
|
130
|
+
if (state.activeAccountManager !== previousManager) {
|
|
131
|
+
// Another path (native storage sync, stale recovery) already swapped
|
|
132
|
+
// the manager while disk I/O was pending; never clobber its redirect.
|
|
133
|
+
state.lastCredentialReloadAt = Date.now();
|
|
134
|
+
return state.activeAccountManager;
|
|
135
|
+
}
|
|
136
|
+
reloaded.setRoutingMutexMode(state.routingMutexMode);
|
|
137
|
+
const freshAccounts = reloaded.getAccountsSnapshot();
|
|
138
|
+
// loadFromDisk intentionally converts an unreadable/corrupt source with no
|
|
139
|
+
// usable WAL/backup into an empty manager. Do not let a transient read
|
|
140
|
+
// failure erase a healthy resident pool; a parsed empty/reset snapshot is
|
|
141
|
+
// still authoritative and may intentionally remove every account.
|
|
142
|
+
if (authBeforeLoad.length > 0 &&
|
|
143
|
+
freshAccounts.length === 0 &&
|
|
144
|
+
!reloaded.hasLoadedAccountStorageSnapshot()) {
|
|
145
|
+
state.lastCredentialReloadAt = Date.now();
|
|
146
|
+
return previousManager;
|
|
147
|
+
}
|
|
148
|
+
// A refresh can finish upstream while disk I/O is pending and then queue
|
|
149
|
+
// its auth commit against the resident manager. Let every commit already
|
|
150
|
+
// targeting that manager settle before taking the final live snapshot.
|
|
151
|
+
await waitForRuntimeRefreshCommits(previousManager);
|
|
152
|
+
if (state.activeAccountManager !== previousManager) {
|
|
153
|
+
state.lastCredentialReloadAt = Date.now();
|
|
154
|
+
return state.activeAccountManager;
|
|
155
|
+
}
|
|
156
|
+
const previousAccounts = previousManager.getAccountsSnapshot();
|
|
157
|
+
const credentialInventoryFingerprint = (accounts) => JSON.stringify(accounts.map((account) => ({
|
|
158
|
+
recordId: account.recordId ?? null,
|
|
159
|
+
accountId: account.accountId?.trim() ?? null,
|
|
160
|
+
accountIdSource: account.accountIdSource ?? null,
|
|
161
|
+
email: sanitizeEmail(account.email) ?? null,
|
|
162
|
+
refreshToken: account.refreshToken,
|
|
163
|
+
access: account.access ?? null,
|
|
164
|
+
expires: account.expires ?? null,
|
|
165
|
+
enabled: account.enabled !== false,
|
|
166
|
+
authInvalidatedAt: account.authInvalidatedAt ?? null,
|
|
167
|
+
authInvalidationErrorCode: account.authInvalidationErrorCode ?? null,
|
|
168
|
+
workspaces: (account.workspaces ?? []).map((workspace) => ({
|
|
169
|
+
id: workspace.id.trim(),
|
|
170
|
+
name: workspace.name ?? null,
|
|
171
|
+
enabled: workspace.enabled !== false,
|
|
172
|
+
disabledAt: workspace.disabledAt ?? null,
|
|
173
|
+
isDefault: workspace.isDefault ?? null,
|
|
174
|
+
})),
|
|
175
|
+
currentWorkspaceIndex: account.currentWorkspaceIndex ?? null,
|
|
176
|
+
})));
|
|
177
|
+
// A credential probe that finds the exact same inventory must not swap the
|
|
178
|
+
// live manager. Returning the resident object preserves inference cursors,
|
|
179
|
+
// lastUsed state, and in-flight runtime observations while still bounding
|
|
180
|
+
// disk checks through lastCredentialReloadAt.
|
|
181
|
+
if (credentialInventoryFingerprint(authBeforeLoad) === credentialInventoryFingerprint(freshAccounts)) {
|
|
182
|
+
// Cooldown fields are deliberately excluded from the inventory fingerprint
|
|
183
|
+
// so a disk probe cannot reset inference selector state. Still absorb a
|
|
184
|
+
// newer persisted auth-failure cooldown from another proxy, but only when
|
|
185
|
+
// the credential itself is unchanged; a re-login/rotation repairs that wait.
|
|
186
|
+
for (const fresh of freshAccounts) {
|
|
187
|
+
const resident = previousManager.getAccountByIndex(fresh.index);
|
|
188
|
+
if (resident &&
|
|
189
|
+
fresh.cooldownReason === "auth-failure" &&
|
|
190
|
+
typeof fresh.coolingDownUntil === "number" &&
|
|
191
|
+
fresh.coolingDownUntil > state.now() &&
|
|
192
|
+
resident.access === fresh.access &&
|
|
193
|
+
resident.refreshToken === fresh.refreshToken &&
|
|
194
|
+
fresh.coolingDownUntil > (resident.coolingDownUntil ?? 0)) {
|
|
195
|
+
resident.coolingDownUntil = fresh.coolingDownUntil;
|
|
196
|
+
resident.cooldownReason = "auth-failure";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
state.lastCredentialReloadAt = Date.now();
|
|
200
|
+
return previousManager;
|
|
201
|
+
}
|
|
202
|
+
const baselineFor = (previous) => authBeforeLoad.find((account) => (account.recordId && account.recordId === previous.recordId) ||
|
|
203
|
+
account.index === previous.index);
|
|
204
|
+
const workspaceIds = (account) => new Set([
|
|
205
|
+
account.accountId?.trim(),
|
|
206
|
+
...(account.workspaces ?? []).map((workspace) => workspace.id.trim()),
|
|
207
|
+
].filter((value) => Boolean(value)));
|
|
208
|
+
const hasWorkspaceConflict = (previous, baseline, candidate) => {
|
|
209
|
+
const sourceIds = workspaceIds(previous);
|
|
210
|
+
if (baseline) {
|
|
211
|
+
for (const id of workspaceIds(baseline))
|
|
212
|
+
sourceIds.add(id);
|
|
213
|
+
}
|
|
214
|
+
const candidateIds = workspaceIds(candidate);
|
|
215
|
+
return sourceIds.size > 0 && candidateIds.size > 0 &&
|
|
216
|
+
![...sourceIds].some((id) => candidateIds.has(id));
|
|
217
|
+
};
|
|
218
|
+
const claimed = new Set();
|
|
219
|
+
const matchedFreshIndexByPrevious = new Map();
|
|
220
|
+
const reserve = (previous, candidate) => {
|
|
221
|
+
if (!candidate || claimed.has(candidate.index))
|
|
222
|
+
return false;
|
|
223
|
+
claimed.add(candidate.index);
|
|
224
|
+
matchedFreshIndexByPrevious.set(previous.index, candidate.index);
|
|
225
|
+
return true;
|
|
226
|
+
};
|
|
227
|
+
// Reserve every strong match for every old row before considering fuzzy
|
|
228
|
+
// fallbacks. This prevents an earlier deleted row from stealing the only
|
|
229
|
+
// surviving same-email workspace before that survivor can claim itself.
|
|
230
|
+
for (const previous of previousAccounts) {
|
|
231
|
+
if (!previous.recordId)
|
|
232
|
+
continue;
|
|
233
|
+
const matches = freshAccounts.filter((account) => !claimed.has(account.index) && account.recordId === previous.recordId);
|
|
234
|
+
if (matches.length === 1)
|
|
235
|
+
reserve(previous, matches[0]);
|
|
236
|
+
}
|
|
237
|
+
for (const previous of previousAccounts) {
|
|
238
|
+
if (matchedFreshIndexByPrevious.has(previous.index))
|
|
239
|
+
continue;
|
|
240
|
+
const baseline = baselineFor(previous);
|
|
241
|
+
const identities = new Set([identityOf(previous), baseline ? identityOf(baseline) : null].filter((identity) => identity !== null));
|
|
242
|
+
const matches = freshAccounts.filter((account) => {
|
|
243
|
+
if (claimed.has(account.index))
|
|
244
|
+
return false;
|
|
245
|
+
const identity = identityOf(account);
|
|
246
|
+
return identity !== null && identities.has(identity);
|
|
247
|
+
});
|
|
248
|
+
if (matches.length === 1)
|
|
249
|
+
reserve(previous, matches[0]);
|
|
250
|
+
}
|
|
251
|
+
for (const previous of previousAccounts) {
|
|
252
|
+
if (matchedFreshIndexByPrevious.has(previous.index))
|
|
253
|
+
continue;
|
|
254
|
+
const baseline = baselineFor(previous);
|
|
255
|
+
const refreshTokens = new Set([previous.refreshToken, baseline?.refreshToken].filter((value) => Boolean(value)));
|
|
256
|
+
const matches = freshAccounts.filter((account) => !claimed.has(account.index) && refreshTokens.has(account.refreshToken));
|
|
257
|
+
if (matches.length === 1)
|
|
258
|
+
reserve(previous, matches[0]);
|
|
259
|
+
}
|
|
260
|
+
for (const previous of previousAccounts) {
|
|
261
|
+
if (matchedFreshIndexByPrevious.has(previous.index))
|
|
262
|
+
continue;
|
|
263
|
+
const baseline = baselineFor(previous);
|
|
264
|
+
const stableEmail = sanitizeEmail(previous.email) ?? sanitizeEmail(baseline?.email);
|
|
265
|
+
if (!stableEmail)
|
|
266
|
+
continue;
|
|
267
|
+
const matches = freshAccounts.filter((account) => !claimed.has(account.index) &&
|
|
268
|
+
sanitizeEmail(account.email) === stableEmail &&
|
|
269
|
+
!hasWorkspaceConflict(previous, baseline, account));
|
|
270
|
+
if (matches.length === 1)
|
|
271
|
+
reserve(previous, matches[0]);
|
|
272
|
+
}
|
|
273
|
+
for (const previous of previousAccounts) {
|
|
274
|
+
const matchedIndex = matchedFreshIndexByPrevious.get(previous.index);
|
|
275
|
+
if (matchedIndex === undefined)
|
|
276
|
+
continue;
|
|
277
|
+
const baseline = baselineFor(previous);
|
|
278
|
+
const live = reloaded.getAccountByIndex(matchedIndex);
|
|
279
|
+
if (!live)
|
|
280
|
+
continue;
|
|
281
|
+
const authRotatedDuringLoad = baseline !== undefined && (baseline.access !== previous.access ||
|
|
282
|
+
baseline.refreshToken !== previous.refreshToken ||
|
|
283
|
+
baseline.expires !== previous.expires);
|
|
284
|
+
if (authRotatedDuringLoad) {
|
|
285
|
+
live.access = previous.access;
|
|
286
|
+
live.refreshToken = previous.refreshToken;
|
|
287
|
+
live.expires = previous.expires;
|
|
288
|
+
const tokenBoundIdentityChanged = baseline !== undefined && (baseline.accountId !== previous.accountId ||
|
|
289
|
+
baseline.accountIdSource !== previous.accountIdSource);
|
|
290
|
+
const liveHasExplicitBinding = live.accountIdSource === "manual" || live.accountIdSource === "org";
|
|
291
|
+
const previousHasTokenBinding = previous.accountIdSource === "token" || previous.accountIdSource === "id_token";
|
|
292
|
+
if (tokenBoundIdentityChanged && previousHasTokenBinding && !liveHasExplicitBinding) {
|
|
293
|
+
live.accountId = previous.accountId;
|
|
294
|
+
live.accountIdSource = previous.accountIdSource;
|
|
295
|
+
}
|
|
296
|
+
if (baseline !== undefined && baseline.email !== previous.email) {
|
|
297
|
+
live.email = previous.email;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const [key, until] of Object.entries(previous.rateLimitResetTimes)) {
|
|
301
|
+
if (typeof until === "number" && until > state.now()) {
|
|
302
|
+
live.rateLimitResetTimes[key] = Math.max(live.rateLimitResetTimes[key] ?? 0, until);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if ((previous.coolingDownUntil ?? 0) > (live.coolingDownUntil ?? 0)) {
|
|
306
|
+
const repairedAuth = previous.cooldownReason === "auth-failure" && previous.access !== live.access;
|
|
307
|
+
if (!repairedAuth) {
|
|
308
|
+
live.coolingDownUntil = previous.coolingDownUntil;
|
|
309
|
+
live.cooldownReason = previous.cooldownReason;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const sameCredential = live.access === previous.access && live.refreshToken === previous.refreshToken;
|
|
313
|
+
// Auth-invalidation and workspace-disable markers are written in memory
|
|
314
|
+
// first and only reach disk through the debounced save. Carry only a
|
|
315
|
+
// marker that landed during the load window (absent from the baseline):
|
|
316
|
+
// a pre-existing marker may have been legitimately cleared on disk by
|
|
317
|
+
// another process, which is then authoritative. A re-login (rotated
|
|
318
|
+
// credential) legitimately clears them either way.
|
|
319
|
+
const baselineInvalidated = baseline !== undefined && previousManager.isAccountAuthInvalidated(baseline);
|
|
320
|
+
if (sameCredential &&
|
|
321
|
+
!baselineInvalidated &&
|
|
322
|
+
previousManager.isAccountAuthInvalidated(previous) &&
|
|
323
|
+
!reloaded.isAccountAuthInvalidated(live)) {
|
|
324
|
+
live.authInvalidatedAt = previous.authInvalidatedAt;
|
|
325
|
+
live.authInvalidationErrorCode = previous.authInvalidationErrorCode;
|
|
326
|
+
}
|
|
327
|
+
if (sameCredential) {
|
|
328
|
+
for (const workspace of previous.workspaces ?? []) {
|
|
329
|
+
const baselineWorkspace = baseline?.workspaces?.find((item) => item.id === workspace.id);
|
|
330
|
+
// A disable that predates the load is already in the baseline —
|
|
331
|
+
// disk may have re-enabled the workspace, and that wins.
|
|
332
|
+
if (baselineWorkspace?.enabled === false)
|
|
333
|
+
continue;
|
|
334
|
+
const target = live.workspaces?.find((item) => item.id === workspace.id);
|
|
335
|
+
if (target &&
|
|
336
|
+
workspace.enabled === false &&
|
|
337
|
+
workspace.disabledAt !== undefined &&
|
|
338
|
+
target.enabled !== false) {
|
|
339
|
+
target.enabled = false;
|
|
340
|
+
target.disabledAt = workspace.disabledAt;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Requests that captured the resident manager before this swap may finish
|
|
346
|
+
// refreshing afterward. Redirect those late auth commits to the replacement
|
|
347
|
+
// so rotated single-use refresh tokens cannot be stranded on the old object.
|
|
348
|
+
redirectRuntimeRefreshCommits(previousManager, reloaded);
|
|
349
|
+
state.activeAccountManager = reloaded;
|
|
350
|
+
state.knownAccountManagers.add(reloaded);
|
|
351
|
+
state.lastCredentialReloadAt = Date.now();
|
|
352
|
+
recordRuntimeReload("credential-inventory-refresh");
|
|
353
|
+
return reloaded;
|
|
354
|
+
})()
|
|
355
|
+
.catch((error) => {
|
|
356
|
+
state.status.lastError = error instanceof Error ? error.message : String(error);
|
|
357
|
+
return null;
|
|
358
|
+
})
|
|
359
|
+
.finally(() => {
|
|
360
|
+
state.credentialReloadPromise = null;
|
|
361
|
+
});
|
|
362
|
+
return state.credentialReloadPromise;
|
|
363
|
+
}
|
|
29
364
|
/** @internal */
|
|
30
365
|
export async function recoverStaleRuntimeState(state) {
|
|
366
|
+
if (state.staleRuntimeReloadPromise)
|
|
367
|
+
return state.staleRuntimeReloadPromise;
|
|
368
|
+
// If a non-destructive credential refresh started first, let it publish its
|
|
369
|
+
// inventory before performing the authoritative stale-state reset. Re-check
|
|
370
|
+
// the stale promise afterward so concurrent recovery callers still dedupe.
|
|
371
|
+
if (state.credentialReloadPromise)
|
|
372
|
+
await state.credentialReloadPromise;
|
|
373
|
+
if (state.staleRuntimeReloadPromise)
|
|
374
|
+
return state.staleRuntimeReloadPromise;
|
|
31
375
|
if (Date.now() - state.lastStaleRuntimeReloadAt <= STALE_RUNTIME_RELOAD_DEDUPE_MS) {
|
|
32
376
|
return state.activeAccountManager;
|
|
33
377
|
}
|
|
34
|
-
if (state.staleRuntimeReloadPromise) {
|
|
35
|
-
return state.staleRuntimeReloadPromise;
|
|
36
|
-
}
|
|
37
378
|
state.staleRuntimeReloadPromise = (async () => {
|
|
379
|
+
const previousManager = state.activeAccountManager;
|
|
380
|
+
const authBeforeLoad = previousManager.getAccountsSnapshot();
|
|
38
381
|
AccountManager.resetVolatileRuntimeState();
|
|
39
382
|
recordRuntimeReset("pool-exhausted-no-account");
|
|
40
383
|
const reloaded = await AccountManager.loadFromDisk();
|
|
384
|
+
if (state.activeAccountManager !== previousManager) {
|
|
385
|
+
// A concurrent swap (native storage sync, credential reload) already
|
|
386
|
+
// published a newer manager; discarding this result keeps its redirect
|
|
387
|
+
// chain intact instead of clobbering previous → live with stale.
|
|
388
|
+
state.lastStaleRuntimeReloadAt = Date.now();
|
|
389
|
+
return state.activeAccountManager;
|
|
390
|
+
}
|
|
41
391
|
reloaded.setRoutingMutexMode(state.routingMutexMode);
|
|
392
|
+
// Drain refresh commits that queued against the resident manager while
|
|
393
|
+
// disk I/O was pending, then carry the rotated credentials: the loaded
|
|
394
|
+
// snapshot predates their persist and still holds the spent token.
|
|
395
|
+
await waitForRuntimeRefreshCommits(previousManager);
|
|
396
|
+
if (state.activeAccountManager !== previousManager) {
|
|
397
|
+
state.lastStaleRuntimeReloadAt = Date.now();
|
|
398
|
+
return state.activeAccountManager;
|
|
399
|
+
}
|
|
400
|
+
carryRotatedCredentials(previousManager, authBeforeLoad, reloaded);
|
|
42
401
|
// Wipe per-account cooldowns and rate-limit windows on the freshly
|
|
43
402
|
// reloaded pool. `resetVolatileRuntimeState` above only cleared global
|
|
44
403
|
// singletons (trackers, circuit breakers); the per-account transient
|
|
@@ -63,6 +422,19 @@ export async function recoverStaleRuntimeState(state) {
|
|
|
63
422
|
// durable across a restart, not just best-effort. Recovery is rare
|
|
64
423
|
// (full-pool exhaustion), so the extra synchronous write is cheap.
|
|
65
424
|
await reloaded.flushPendingSave();
|
|
425
|
+
// The flush opened another window in which a late commit could queue on
|
|
426
|
+
// the superseded manager. Drain it, then carry + redirect + swap in one
|
|
427
|
+
// synchronous block so no commit can slip between the drain and the swap.
|
|
428
|
+
await waitForRuntimeRefreshCommits(previousManager);
|
|
429
|
+
if (state.activeAccountManager !== previousManager) {
|
|
430
|
+
state.lastStaleRuntimeReloadAt = Date.now();
|
|
431
|
+
return state.activeAccountManager;
|
|
432
|
+
}
|
|
433
|
+
carryRotatedCredentials(previousManager, authBeforeLoad, reloaded);
|
|
434
|
+
// Keep refresh ownership chains current across destructive recovery too.
|
|
435
|
+
// Requests that captured the previous manager before this swap must never
|
|
436
|
+
// persist a late refresh/cooldown into an object that is no longer active.
|
|
437
|
+
redirectRuntimeRefreshCommits(previousManager, reloaded);
|
|
66
438
|
state.activeAccountManager = reloaded;
|
|
67
439
|
state.knownAccountManagers.add(reloaded);
|
|
68
440
|
state.lastStaleRuntimeReloadAt = Date.now();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WebSocketDisconnectDiagnostic } from "./responses-websocket.js";
|
|
1
2
|
import type { QuotaCacheData } from "../quota-cache.js";
|
|
2
3
|
import type { ApiRouteCredential } from "../api-route-store.js";
|
|
3
4
|
import type { AccountStorageV3 } from "../storage.js";
|
|
@@ -18,10 +19,12 @@ export interface RuntimeRotationProxyServer {
|
|
|
18
19
|
getOpenConnectionCount?: () => number;
|
|
19
20
|
}
|
|
20
21
|
export interface RuntimeRotationProxyStatus {
|
|
22
|
+
schedulingStrategy?: "hybrid" | "sequential";
|
|
21
23
|
streamQuotaUpdates?: number;
|
|
22
24
|
lastStreamQuotaUpdateAt?: number;
|
|
23
25
|
websocketConnections?: number;
|
|
24
26
|
websocketUpstreamRequests?: number;
|
|
27
|
+
lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic;
|
|
25
28
|
startedAt: number;
|
|
26
29
|
totalRequests: number;
|
|
27
30
|
upstreamRequests: number;
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AccountManager, type ManagedAccount } from "../accounts.js";
|
|
2
2
|
import type { ModelFamily } from "../prompts/codex.js";
|
|
3
3
|
/** @internal */
|
|
4
4
|
export declare const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30000;
|
|
5
5
|
/** @internal */
|
|
6
6
|
export declare function applyMonotonicAuthCooldown(accountManager: AccountManager, account: ManagedAccount, cooldownMs: number): void;
|
|
7
|
+
type RuntimeRefreshTarget = {
|
|
8
|
+
accountManager: AccountManager;
|
|
9
|
+
account: ManagedAccount;
|
|
10
|
+
};
|
|
11
|
+
/** @internal Resolve an account onto the latest runtime manager replacement. */
|
|
12
|
+
export declare function resolveRuntimeRefreshTarget(accountManager: AccountManager, account: ManagedAccount): RuntimeRefreshTarget | null;
|
|
13
|
+
/** @internal Test-only reset for process-global refresh commit/redirect state. */
|
|
14
|
+
export declare function resetRuntimeRefreshStateForTests(): void;
|
|
15
|
+
/** @internal Wait until every refresh-auth commit already targeting this manager settles. */
|
|
16
|
+
export declare function waitForRuntimeRefreshCommits(accountManager: AccountManager): Promise<void>;
|
|
17
|
+
/** @internal Redirect late refresh state from requests that captured a superseded manager. */
|
|
18
|
+
export declare function redirectRuntimeRefreshCommits(previousManager: AccountManager, replacementManager: AccountManager): void;
|
|
7
19
|
/** @internal */
|
|
8
20
|
export declare function ensureFreshAccessToken(params: {
|
|
9
21
|
accountManager: AccountManager;
|
|
@@ -17,8 +29,18 @@ export declare function ensureFreshAccessToken(params: {
|
|
|
17
29
|
ok: true;
|
|
18
30
|
accessToken: string;
|
|
19
31
|
account: ManagedAccount;
|
|
32
|
+
accountManager: AccountManager;
|
|
20
33
|
} | {
|
|
21
34
|
ok: false;
|
|
22
35
|
retryable: boolean;
|
|
23
36
|
invalidated?: boolean;
|
|
37
|
+
selectionRestart?: false;
|
|
38
|
+
account: ManagedAccount;
|
|
39
|
+
accountManager: AccountManager;
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
retryable: true;
|
|
43
|
+
selectionRestart: true;
|
|
44
|
+
accountManager: AccountManager;
|
|
24
45
|
}>;
|
|
46
|
+
export {};
|