@crouton-kit/crouter 0.3.55 → 0.3.56
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/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +612 -130
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +118 -31
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +60 -60
- package/dist/web-client/assets/index-C6rGKvf2.css +2 -0
- package/dist/web-client/assets/index-rvMEymEb.js +77 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-client/assets/index-DNmTmdXt.js +0 -77
- package/dist/web-client/assets/index-DuyYI5Sg.css +0 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import {
|
|
4
5
|
createAssistantMessageEventStream,
|
|
@@ -65,9 +66,10 @@ type AttemptResult = {
|
|
|
65
66
|
// (connection reset, socket errors, timeouts, plain 500/502/504, or 503 with NO
|
|
66
67
|
// retry-after) is not evidence the credential itself is bad -- it gets a short in-place
|
|
67
68
|
// retry on the same credential, never a cooldown. Everything else is fatal. Bare
|
|
68
|
-
// `usage`/`quota` substrings
|
|
69
|
-
// magnets (e.g. "usage" appears in normal assistant text)
|
|
70
|
-
//
|
|
69
|
+
// `usage`/`quota` substrings stay out of the message match: they are false-positive
|
|
70
|
+
// magnets (e.g. "usage" appears in normal assistant text), while the precise
|
|
71
|
+
// terminal usage-limit signals above still cool the credential down and rotate to the
|
|
72
|
+
// next one.
|
|
71
73
|
type ProviderErrorClassification = {
|
|
72
74
|
kind: "rate_limit" | "transient" | "fatal";
|
|
73
75
|
status?: number;
|
|
@@ -120,12 +122,17 @@ export function __setSleepForTest(fn: typeof defaultSleep | undefined): void {
|
|
|
120
122
|
activeSleep = fn ?? defaultSleep;
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
// Parse `status=400` from pi-ai's plain Error message so genuine invalid_grant reaches
|
|
126
|
+
// cooldown / reauth / fallback; the typed status field is still honored for tests and other
|
|
127
|
+
// callers that provide one.
|
|
123
128
|
function isInvalidRefreshTokenError(error: unknown): boolean {
|
|
124
129
|
const value = error as { status?: unknown; errorMessage?: unknown; message?: unknown } | undefined;
|
|
125
|
-
const
|
|
130
|
+
const typedStatus = typeof value?.status === "number" ? value.status : undefined;
|
|
126
131
|
const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ""]
|
|
127
132
|
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
|
128
133
|
.join(" ");
|
|
134
|
+
const messageStatusMatch = message.match(/\bstatus[=:]\s*(\d+)/i);
|
|
135
|
+
const status = typedStatus ?? (messageStatusMatch ? Number(messageStatusMatch[1]) : Number.NaN);
|
|
129
136
|
return status === 400 && /invalid_grant|refresh token .*invalid|refresh token .*not found/i.test(message);
|
|
130
137
|
}
|
|
131
138
|
|
|
@@ -192,8 +199,16 @@ function retryAfterMs(headers: Record<string, string>): number | undefined {
|
|
|
192
199
|
return parseRetryAfterHeader(headers["retry-after"]);
|
|
193
200
|
}
|
|
194
201
|
|
|
195
|
-
type RefreshedCredential =
|
|
196
|
-
type LoginCredential =
|
|
202
|
+
type RefreshedCredential = { refresh: string; access: string; expires: number };
|
|
203
|
+
type LoginCredential = { refresh: string; access: string; expires: number };
|
|
204
|
+
|
|
205
|
+
// The default slot's live value only ever exists inside auth.json (see
|
|
206
|
+
// `refreshDefaultSlotCredential`) -- once a credential has been through refresh/reauth for
|
|
207
|
+
// this turn, `.access` is guaranteed populated (whether it came from the pool's own stored
|
|
208
|
+
// value for a non-default account, or was just read/refreshed out of auth.json for the
|
|
209
|
+
// default slot). Downstream stream-attempt code relies on `.access` being present rather
|
|
210
|
+
// than re-deriving "is this the default slot" logic at every call site.
|
|
211
|
+
type ResolvedCredential = SubscriptionCredential & { access: string };
|
|
197
212
|
|
|
198
213
|
async function defaultRefreshForProvider(providerId: ManagedProviderId, refreshToken: string): Promise<RefreshedCredential> {
|
|
199
214
|
return providerId === ANTHROPIC_PROVIDER_ID ? refreshAnthropicToken(refreshToken) : refreshOpenAICodexToken(refreshToken);
|
|
@@ -225,16 +240,66 @@ function openBrowser(url: string): void {
|
|
|
225
240
|
}
|
|
226
241
|
}
|
|
227
242
|
|
|
228
|
-
|
|
229
|
-
|
|
243
|
+
type AuthCredentialRecord = { type?: string; refresh?: string; access?: string; expires?: number; [key: string]: unknown };
|
|
244
|
+
type AuthFileData = Record<string, AuthCredentialRecord>;
|
|
245
|
+
|
|
246
|
+
// The default slot (`label === providerId`) reads and refreshes its live value from pi's
|
|
247
|
+
// auth.json (design §1/§3). This delegates the whole read -> check-expiry -> refresh ->
|
|
248
|
+
// write transaction to pi's `FileAuthStorageBackend.withLockAsync`, which holds a real
|
|
249
|
+
// cross-process file lock, re-reads `current` under the lock on every acquisition, and
|
|
250
|
+
// releases in a `finally` even if the callback throws. A second caller that reaches the
|
|
251
|
+
// lock after a refresh winner sees the rotated `access`/`expires` and returns that value
|
|
252
|
+
// without calling the network refresh on the stale token. We deliberately do NOT route
|
|
253
|
+
// through pi's public `getApiKey` because it wraps pi-ai's `getOAuthApiKey`, which
|
|
254
|
+
// collapses the refresh failure into a generic message; calling our own
|
|
255
|
+
// `activeRefreshForProvider` inside the callback keeps the real error intact so
|
|
256
|
+
// `isInvalidRefreshTokenError` can classify the failure (cooldown -> reauth ->
|
|
257
|
+
// cross-provider-fallback) exactly as it does for non-default accounts.
|
|
258
|
+
async function refreshDefaultSlotCredential(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential> {
|
|
259
|
+
const backend = new FileAuthStorageBackend();
|
|
260
|
+
const access = await backend.withLockAsync(async (current) => {
|
|
261
|
+
const data: AuthFileData = current ? JSON.parse(current) : {};
|
|
262
|
+
const cred = data[providerId];
|
|
263
|
+
// Only an OAuth-typed record is the default slot's refreshable credential: an
|
|
264
|
+
// api_key record (or no record at all) has no refresh token to refresh, so it is never
|
|
265
|
+
// treated as fresh or refreshable here.
|
|
266
|
+
if (cred?.type === "oauth" && cred.access && typeof cred.expires === "number" && Date.now() < cred.expires) {
|
|
267
|
+
return { result: cred.access };
|
|
268
|
+
}
|
|
269
|
+
if (cred?.type !== "oauth" || !cred.refresh) {
|
|
270
|
+
throw new Error(`${getProviderLabel(providerId)} is not authenticated — run \`crtr sys setup\` (or /provider-sub ${providerId} add)`);
|
|
271
|
+
}
|
|
272
|
+
const refreshed = await activeRefreshForProvider(providerId, cred.refresh);
|
|
273
|
+
data[providerId] = { ...cred, type: "oauth", refresh: refreshed.refresh, access: refreshed.access, expires: refreshed.expires };
|
|
274
|
+
return { result: refreshed.access, next: `${JSON.stringify(data, null, 2)}\n` };
|
|
275
|
+
});
|
|
276
|
+
return { ...credential, access };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function persistDefaultSlotCredential(providerId: ManagedProviderId, credential: RefreshedCredential): Promise<void> {
|
|
280
|
+
const backend = new FileAuthStorageBackend();
|
|
281
|
+
await backend.withLockAsync(async (current) => {
|
|
282
|
+
const data: AuthFileData = current ? JSON.parse(current) : {};
|
|
283
|
+
data[providerId] = { type: "oauth", ...credential };
|
|
284
|
+
return { result: undefined, next: `${JSON.stringify(data, null, 2)}\n` };
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function refreshCredentialIfNeeded(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential> {
|
|
289
|
+
if (credential.label === providerId) return refreshDefaultSlotCredential(providerId, credential);
|
|
290
|
+
if (!credential.refresh || !credential.access || credential.expires === undefined) {
|
|
291
|
+
throw new Error(`${getProviderLabel(providerId)} subscription "${credential.label}" is missing its credential value`);
|
|
292
|
+
}
|
|
293
|
+
if (credential.expires > Date.now()) return credential as ResolvedCredential;
|
|
230
294
|
const refreshed = await activeRefreshForProvider(providerId, credential.refresh);
|
|
231
|
-
const next = {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
295
|
+
const next: SubscriptionCredential = { ...credential, refresh: refreshed.refresh, access: refreshed.access, expires: refreshed.expires };
|
|
296
|
+
return (upsertSubscription(providerId, next).find((entry) => entry.label === next.label) ?? next) as ResolvedCredential;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Test-only seam: exercises the real `refreshCredentialIfNeeded` (including the default-slot
|
|
300
|
+
* `withLockAsync` path) without duplicating its logic. */
|
|
301
|
+
export async function __refreshCredentialIfNeededForTest(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential> {
|
|
302
|
+
return refreshCredentialIfNeeded(providerId, credential);
|
|
238
303
|
}
|
|
239
304
|
|
|
240
305
|
async function defaultLoginForProvider(providerId: ManagedProviderId, ctx: ExtensionContext): Promise<LoginCredential> {
|
|
@@ -248,11 +313,19 @@ export function __setLoginForProviderForTest(fn: typeof defaultLoginForProvider
|
|
|
248
313
|
activeLoginForProvider = fn ?? defaultLoginForProvider;
|
|
249
314
|
}
|
|
250
315
|
|
|
251
|
-
async function reauthenticateCredential(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<
|
|
316
|
+
async function reauthenticateCredential(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<ResolvedCredential | undefined> {
|
|
252
317
|
const ctx = runtimeContext;
|
|
253
318
|
if (!ctx?.hasUI) return undefined;
|
|
254
319
|
ctx.ui.notify(`${getProviderLabel(providerId)} subscription "${credential.label}" needs re-authentication. Opening OAuth login...`, "warn");
|
|
255
320
|
const authenticated = await activeLoginForProvider(providerId, ctx);
|
|
321
|
+
if (credential.label === providerId) {
|
|
322
|
+
// Same single owner as the refresh path: persist the reauthenticated value to
|
|
323
|
+
// auth.json through the same locked FileAuthStorageBackend seam the refresh path uses.
|
|
324
|
+
await persistDefaultSlotCredential(providerId, authenticated);
|
|
325
|
+
const pool = markSubscriptionSuccess(providerId, credential.label, Date.now());
|
|
326
|
+
const meta = pool.find((entry) => entry.label === credential.label) ?? credential;
|
|
327
|
+
return { ...meta, access: authenticated.access };
|
|
328
|
+
}
|
|
256
329
|
const next: SubscriptionCredential = {
|
|
257
330
|
...credential,
|
|
258
331
|
refresh: authenticated.refresh,
|
|
@@ -262,7 +335,7 @@ async function reauthenticateCredential(providerId: ManagedProviderId, credentia
|
|
|
262
335
|
lastAttemptAt: Date.now(),
|
|
263
336
|
lastRateLimitedAt: credential.lastRateLimitedAt,
|
|
264
337
|
};
|
|
265
|
-
return upsertSubscription(providerId, next).find((entry) => entry.label === next.label) ?? next;
|
|
338
|
+
return (upsertSubscription(providerId, next).find((entry) => entry.label === next.label) ?? next) as ResolvedCredential;
|
|
266
339
|
}
|
|
267
340
|
|
|
268
341
|
function defaultStreamForProvider(model: Model<any>, context: Context, options: SimpleStreamOptions | undefined): AssistantMessageEventStream {
|
|
@@ -293,7 +366,7 @@ async function streamProviderAttempt(
|
|
|
293
366
|
model: Model<any>,
|
|
294
367
|
context: Context,
|
|
295
368
|
options: SimpleStreamOptions | undefined,
|
|
296
|
-
credential:
|
|
369
|
+
credential: ResolvedCredential,
|
|
297
370
|
outer: AssistantMessageEventStream,
|
|
298
371
|
): Promise<AttemptResult> {
|
|
299
372
|
for (let attempt = 1; ; attempt++) {
|
|
@@ -465,14 +538,14 @@ async function runManagedProvider(
|
|
|
465
538
|
const available = rawPool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= Date.now());
|
|
466
539
|
|
|
467
540
|
for (const credential of available) {
|
|
468
|
-
let freshCredential:
|
|
541
|
+
let freshCredential: ResolvedCredential;
|
|
469
542
|
try {
|
|
470
543
|
freshCredential = await refreshCredentialIfNeeded(providerId, credential);
|
|
471
544
|
} catch (error) {
|
|
472
545
|
const attemptAt = Date.now();
|
|
473
546
|
markSubscriptionAttempt(providerId, credential.label, attemptAt);
|
|
474
547
|
if (isInvalidRefreshTokenError(error)) {
|
|
475
|
-
let reauthenticated:
|
|
548
|
+
let reauthenticated: ResolvedCredential | undefined;
|
|
476
549
|
try {
|
|
477
550
|
reauthenticated = await reauthenticateCredential(providerId, credential);
|
|
478
551
|
} catch (loginError) {
|
|
@@ -718,21 +791,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
718
791
|
}
|
|
719
792
|
const label = rest.join(" ") || (await ctx.ui.input(`${getProviderLabel(providerId)} subscription label`, providerId));
|
|
720
793
|
if (!label) return;
|
|
721
|
-
|
|
794
|
+
// The default label (`label === providerId`) is the OAuth-login-linked default slot,
|
|
795
|
+
// whose live value lives exclusively in auth.json (design §1/§3). Accepting it bypasses
|
|
796
|
+
// the duplicate-label check because the metadata-only pool entry for it may already exist,
|
|
797
|
+
// and the value never gets written into the pool: `writeSubscriptionPool` strips any
|
|
798
|
+
// `label === providerId` value on write so the login credential stays in auth.json.
|
|
799
|
+
const isDefaultLabel = label.toLowerCase() === providerId.toLowerCase();
|
|
800
|
+
if (!isDefaultLabel && pool.some((entry) => entry.label.toLowerCase() === label.toLowerCase())) {
|
|
722
801
|
ctx.ui.notify(`${getProviderLabel(providerId)} subscription already exists: ${label}`, "error");
|
|
723
802
|
return;
|
|
724
803
|
}
|
|
725
804
|
try {
|
|
726
|
-
const cred = await
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
805
|
+
const cred = await activeLoginForProvider(providerId, ctx);
|
|
806
|
+
if (isDefaultLabel) {
|
|
807
|
+
// Same single-owner write as `reauthenticateCredential`'s default branch: persist
|
|
808
|
+
// to auth.json through the same locked FileAuthStorageBackend seam; the
|
|
809
|
+
// metadata-only pool entry is (re)created on next read.
|
|
810
|
+
await persistDefaultSlotCredential(providerId, cred);
|
|
811
|
+
markSubscriptionSuccess(providerId, providerId, Date.now());
|
|
812
|
+
} else {
|
|
813
|
+
upsertSubscription(providerId, {
|
|
814
|
+
label,
|
|
815
|
+
refresh: cred.refresh,
|
|
816
|
+
access: cred.access,
|
|
817
|
+
expires: cred.expires,
|
|
818
|
+
rateLimitedUntil: 0,
|
|
819
|
+
lastAttemptAt: 0,
|
|
820
|
+
lastRateLimitedAt: 0,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
736
823
|
logRotation(`added ${getProviderLabel(providerId)} subscription "${label}"`);
|
|
737
824
|
ctx.ui.notify(`Added ${getProviderLabel(providerId)} subscription: ${label}`, "info");
|
|
738
825
|
updateRotationStatus(ctx);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
5
|
+
import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
|
|
7
8
|
export const ANTHROPIC_PROVIDER_ID = "anthropic";
|
|
@@ -11,11 +12,16 @@ export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_COD
|
|
|
11
12
|
export type LadderProviderId = "anthropic" | "openai";
|
|
12
13
|
export type ModelStrength = "ultra" | "strong" | "medium" | "light";
|
|
13
14
|
|
|
15
|
+
// The default label's slot (`label === providerId`) is the OAuth-login-linked credential --
|
|
16
|
+
// its live value (`refresh`/`access`/`expires`) is owned exclusively by pi's auth.json (see
|
|
17
|
+
// provider-rotation.ts's `refreshDefaultSlotCredential`/`reauthenticateCredential`), so those
|
|
18
|
+
// fields are absent on that entry. Any OTHER explicitly-added account (`/provider-sub add`,
|
|
19
|
+
// label !== providerId) keeps the full value here, unchanged.
|
|
14
20
|
export type SubscriptionCredential = {
|
|
15
21
|
label: string;
|
|
16
|
-
refresh
|
|
17
|
-
access
|
|
18
|
-
expires
|
|
22
|
+
refresh?: string;
|
|
23
|
+
access?: string;
|
|
24
|
+
expires?: number;
|
|
19
25
|
rateLimitedUntil: number;
|
|
20
26
|
lastAttemptAt: number;
|
|
21
27
|
lastRateLimitedAt: number;
|
|
@@ -48,10 +54,7 @@ export type FallbackTarget = {
|
|
|
48
54
|
|
|
49
55
|
type ModelLadderConfig = Partial<Record<ModelStrength, string>>;
|
|
50
56
|
type ModelLaddersConfig = Partial<Record<LadderProviderId, ModelLadderConfig>>;
|
|
51
|
-
type AuthFile = Record<string, OAuthCredentials & { type?: string }>;
|
|
52
|
-
|
|
53
57
|
const AGENT_DIR = join(homedir(), ".pi", "agent");
|
|
54
|
-
const AUTH_FILE = join(AGENT_DIR, "auth.json");
|
|
55
58
|
const ROTATION_CONFIG_FILE = join(AGENT_DIR, "provider-rotation.json");
|
|
56
59
|
const CROUTER_CONFIG_FILE = join(homedir(), ".crouter", "config.json");
|
|
57
60
|
const DEFAULT_FALLBACK_STRENGTH: ModelStrength = "strong";
|
|
@@ -105,16 +108,14 @@ function normalizeTiming(value: unknown): number {
|
|
|
105
108
|
|
|
106
109
|
function normalizeCredential(value: Partial<SubscriptionCredential> & { label?: string }): SubscriptionCredential | undefined {
|
|
107
110
|
const label = typeof value.label === "string" ? value.label.trim() : "";
|
|
111
|
+
if (!label) return undefined;
|
|
108
112
|
const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
|
|
109
113
|
const access = typeof value.access === "string" ? value.access.trim() : "";
|
|
110
|
-
if (!label || !refresh || !access) return undefined;
|
|
111
114
|
const expires = Number(value.expires);
|
|
112
115
|
const rateLimitedUntil = Number(value.rateLimitedUntil);
|
|
113
116
|
return {
|
|
114
117
|
label,
|
|
115
|
-
refresh,
|
|
116
|
-
access,
|
|
117
|
-
expires: Number.isFinite(expires) ? expires : 0,
|
|
118
|
+
...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
|
|
118
119
|
rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
|
|
119
120
|
lastAttemptAt: normalizeTiming(value.lastAttemptAt),
|
|
120
121
|
lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
|
|
@@ -126,53 +127,53 @@ function normalizeCredentials(values: unknown): SubscriptionCredential[] {
|
|
|
126
127
|
return values.map((entry) => normalizeCredential(entry as Partial<SubscriptionCredential> & { label?: string })).filter((entry): entry is SubscriptionCredential => Boolean(entry));
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
function readAuthCredentials(): AuthFile {
|
|
130
|
-
return readJsonFile<AuthFile>(AUTH_FILE) ?? {};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
130
|
function getCurrentModelRef(model?: { provider: string; id: string }): ModelRef | undefined {
|
|
134
131
|
return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
|
|
135
132
|
}
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
expires: credential.expires,
|
|
145
|
-
rateLimitedUntil: 0,
|
|
146
|
-
lastAttemptAt: 0,
|
|
147
|
-
lastRateLimitedAt: 0,
|
|
134
|
+
type AuthFileData = Record<string, { type?: string }>;
|
|
135
|
+
|
|
136
|
+
function readAuthCredential(providerId: ManagedProviderId): { type?: string } | undefined {
|
|
137
|
+
return new FileAuthStorageBackend().withLock((current) => {
|
|
138
|
+
if (!current) return { result: undefined };
|
|
139
|
+
const data = JSON.parse(current) as AuthFileData;
|
|
140
|
+
return { result: data[providerId] };
|
|
148
141
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Seeds a METADATA-ONLY pool entry for the default label (`label === providerId`)
|
|
145
|
+
// whenever an OAuth-login-linked credential exists for it in auth.json, regardless of
|
|
146
|
+
// whether the pool already holds OTHER explicit accounts. The auth.json seed read goes
|
|
147
|
+
// through FileAuthStorageBackend's real lock so a held auth.json lock or parse failure is
|
|
148
|
+
// surfaced instead of silently pretending the default credential is absent. Gated
|
|
149
|
+
// specifically on an OAUTH auth.json record (`type === "oauth"`) rather than the presence
|
|
150
|
+
// of any credential, because an api_key credential has no refresh token -- a
|
|
151
|
+
// metadata-only "default" slot seeded for one would be a slot `refreshDefaultSlotCredential`
|
|
152
|
+
// can never actually refresh. No token is ever copied into the pool: the value lives
|
|
153
|
+
// exclusively in auth.json.
|
|
154
|
+
function ensureDefaultSubscriptionMetadata(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
|
|
155
|
+
if (pool.some((entry) => entry.label === providerId)) return pool;
|
|
156
|
+
const authCred = readAuthCredential(providerId);
|
|
157
|
+
if (authCred?.type !== "oauth") return pool;
|
|
158
|
+
const seeded = normalizeCredential({ label: providerId, rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 });
|
|
159
|
+
return seeded ? [...pool, seeded] : pool;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The default label's value lives exclusively in auth.json, so its pool entry must never
|
|
163
|
+
// carry `refresh`/`access`/`expires`; this strips those fields on every read and write,
|
|
164
|
+
// self-healing any pool file that has them physically present on disk. `changed` tells
|
|
165
|
+
// the caller whether anything was actually stripped, so `readSubscriptionPool` can
|
|
166
|
+
// persist the metadata-only result immediately rather than leaving a stale copy on disk
|
|
167
|
+
// until some unrelated write.
|
|
168
|
+
function scrubDefaultValue(providerId: ManagedProviderId, pool: SubscriptionCredential[]): { pool: SubscriptionCredential[]; changed: boolean } {
|
|
169
|
+
let changed = false;
|
|
170
|
+
const next = pool.map((entry) => {
|
|
171
|
+
if (entry.label !== providerId || (entry.refresh === undefined && entry.access === undefined && entry.expires === undefined)) return entry;
|
|
172
|
+
changed = true;
|
|
173
|
+
const { refresh: _refresh, access: _access, expires: _expires, ...meta } = entry;
|
|
174
|
+
return meta;
|
|
175
|
+
});
|
|
176
|
+
return { pool: next, changed };
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
export function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId {
|
|
@@ -185,16 +186,15 @@ export function getProviderLabel(providerId: ManagedProviderId): string {
|
|
|
185
186
|
|
|
186
187
|
export function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[] {
|
|
187
188
|
const poolFile = PROVIDERS[providerId].poolFile;
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return seeded;
|
|
189
|
+
const raw = normalizeCredentials(readJsonFile<unknown>(poolFile));
|
|
190
|
+
const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
|
|
191
|
+
const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
|
|
192
|
+
if (wasScrubbed || withDefaultSlot !== scrubbed) return writeSubscriptionPool(providerId, withDefaultSlot);
|
|
193
|
+
return withDefaultSlot;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
export function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[] {
|
|
197
|
-
const normalized = normalizeCredentials(next);
|
|
197
|
+
const normalized = scrubDefaultValue(providerId, normalizeCredentials(next)).pool;
|
|
198
198
|
writeJsonFile(PROVIDERS[providerId].poolFile, normalized);
|
|
199
199
|
return normalized;
|
|
200
200
|
}
|