@crouton-kit/crouter 0.3.55 → 0.3.57

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.
@@ -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 were dropped from the message match: they are false-positive
69
- // magnets (e.g. "usage" appears in normal assistant text) that used to cool down healthy
70
- // subscriptions for 5 minutes on any transient blip.
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 status = typeof value?.status === "number" ? value.status : Number.NaN;
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 = Pick<SubscriptionCredential, "refresh" | "access" | "expires">;
196
- type LoginCredential = Pick<SubscriptionCredential, "refresh" | "access" | "expires">;
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
- async function refreshCredentialIfNeeded(providerId: ManagedProviderId, credential: SubscriptionCredential): Promise<SubscriptionCredential> {
229
- if (credential.expires > Date.now()) return credential;
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
- ...credential,
233
- refresh: refreshed.refresh,
234
- access: refreshed.access,
235
- expires: refreshed.expires,
236
- };
237
- return upsertSubscription(providerId, next).find((entry) => entry.label === next.label) ?? next;
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<SubscriptionCredential | undefined> {
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: SubscriptionCredential,
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: SubscriptionCredential;
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: SubscriptionCredential | undefined;
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
- if (pool.some((entry) => entry.label.toLowerCase() === label.toLowerCase())) {
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 loginProvider(providerId, ctx);
727
- upsertSubscription(providerId, {
728
- label,
729
- refresh: cred.refresh,
730
- access: cred.access,
731
- expires: cred.expires,
732
- rateLimitedUntil: 0,
733
- lastAttemptAt: 0,
734
- lastRateLimitedAt: 0,
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 { OAuthCredentials, ThinkingLevel } from "@earendil-works/pi-ai";
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: string;
17
- access: string;
18
- expires: number;
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
- function seedSubscriptionsFromAuth(providerId: ManagedProviderId): SubscriptionCredential[] {
138
- const credential = readAuthCredentials()[providerId];
139
- if (!credential) return [];
140
- const seeded = normalizeCredential({
141
- label: providerId,
142
- refresh: credential.refresh,
143
- access: credential.access,
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
- return seeded ? [seeded] : [];
150
- }
151
-
152
- function syncDefaultSubscriptionFromAuth(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
153
- const [seeded] = seedSubscriptionsFromAuth(providerId);
154
- if (!seeded) return pool;
155
- if (pool.length === 0) return [seeded];
156
-
157
- // `/login` ambiently writes the freshly authenticated credential to auth.json.
158
- // If it matches a pool entry by token, sync that entry in place; otherwise it's
159
- // re-auth on the selected/default slot (index 0) whose tokens rotated enough
160
- // that they no longer match, so update that slot rather than appending a new
161
- // entry. Adding a genuinely additional account is an explicit action only:
162
- // `/provider-sub <provider> add <label>`.
163
- const sameCredential = pool.findIndex((entry) => entry.refresh === seeded.refresh || entry.access === seeded.access);
164
- const targetIndex = sameCredential === -1 ? 0 : sameCredential;
165
- const current = pool[targetIndex];
166
- if (current.refresh === seeded.refresh && current.access === seeded.access && current.expires === seeded.expires) return pool;
167
- const next = [...pool];
168
- next[targetIndex] = {
169
- ...current,
170
- refresh: seeded.refresh,
171
- access: seeded.access,
172
- expires: seeded.expires,
173
- rateLimitedUntil: 0,
174
- };
175
- return writeSubscriptionPool(providerId, next);
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 normalized = normalizeCredentials(readJsonFile<unknown>(poolFile));
189
- if (normalized.length > 0) return syncDefaultSubscriptionFromAuth(providerId, normalized);
190
-
191
- const seeded = seedSubscriptionsFromAuth(providerId);
192
- if (seeded.length > 0) writeSubscriptionPool(providerId, seeded);
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
  }