dsh-agy 0.2.2 → 0.2.3

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.
@@ -4,6 +4,34 @@ import { EnvHttpProxyAgent } from "undici";
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import lockfile from "proper-lockfile";
7
+ //#region src/types.ts
8
+ /**
9
+ * Host-independent authentication error. The adapter maps `kind` to the DSH
10
+ * error protocol without coupling the session or CLI layers to dsh-llm.
11
+ */
12
+ var AgyAuthError = class extends Error {
13
+ kind;
14
+ constructor(kind, message, options) {
15
+ super(message, options);
16
+ this.name = "AgyAuthError";
17
+ this.kind = kind;
18
+ }
19
+ };
20
+ /**
21
+ * Enabled accounts exist, but every candidate is temporarily blocked. Kept
22
+ * independent of dsh-llm so CLI and web entry points do not gain a host import.
23
+ */
24
+ var AgyPoolBlockedError = class extends Error {
25
+ kind;
26
+ blockedUntil;
27
+ constructor(kind, blockedUntil) {
28
+ super(kind === "quota-exhausted" ? "All agy accounts have exhausted quota for the requested model family." : "All agy accounts are temporarily blocked for the requested model family.");
29
+ this.name = "AgyPoolBlockedError";
30
+ this.kind = kind;
31
+ this.blockedUntil = blockedUntil;
32
+ }
33
+ };
34
+ //#endregion
7
35
  //#region src/proxy.ts
8
36
  /**
9
37
  * Proxy-aware fetch: respects the standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY
@@ -45,10 +73,32 @@ var constants_exports = /* @__PURE__ */ __exportAll({
45
73
  OAUTH_USERINFO_URL: () => OAUTH_USERINFO_URL,
46
74
  fetchAgyFirstOk: () => fetchAgyFirstOk,
47
75
  getAgyBootstrapClientMetadata: () => getAgyBootstrapClientMetadata,
48
- getAgyBootstrapUserAgent: () => getAgyBootstrapUserAgent
76
+ getAgyBootstrapUserAgent: () => getAgyBootstrapUserAgent,
77
+ resolveAgyClientCredentials: () => resolveAgyClientCredentials
49
78
  });
50
79
  const AGY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
51
80
  const AGY_CLIENT_SECRET = "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";
81
+ /**
82
+ * Effective OAuth client credentials: AGY_CLIENT_ID / AGY_CLIENT_SECRET env
83
+ * overrides win when set (BYO OAuth app escape hatch, mirrors pi-antigravity);
84
+ * otherwise the embedded public Antigravity credentials are used.
85
+ */
86
+ function resolveAgyClientCredentials(overrideClientId) {
87
+ if (overrideClientId) {
88
+ if (overrideClientId === "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com") return {
89
+ clientId: AGY_CLIENT_ID,
90
+ clientSecret: AGY_CLIENT_SECRET
91
+ };
92
+ return {
93
+ clientId: overrideClientId,
94
+ clientSecret: process.env.AGY_CLIENT_SECRET || "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"
95
+ };
96
+ }
97
+ return {
98
+ clientId: process.env.AGY_CLIENT_ID || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
99
+ clientSecret: process.env.AGY_CLIENT_SECRET || "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"
100
+ };
101
+ }
52
102
  /** Required scopes. `openid` must NOT be added: it routes Google into the hanging
53
103
  * `firstparty/nativeapp` consent for this client (verified by OmniRoute). */
54
104
  const AGY_SCOPES = [
@@ -332,8 +382,10 @@ function parseOAuthErrorPayload(text) {
332
382
  /**
333
383
  * Refresh the access token for an account. `revoked` means Google rejected the
334
384
  * refresh token (`invalid_grant`) — the account must be re-authenticated.
385
+ * For legacy accounts without a stored clientId, embedded credentials are tried
386
+ * first with an env-override fallback before treating invalid_grant as fatal.
335
387
  */
336
- async function refreshAccessToken(auth) {
388
+ async function refreshAccessToken(auth, options) {
337
389
  const parts = parseRefreshParts(auth.refresh);
338
390
  if (!parts.refreshToken) return {
339
391
  type: "failed",
@@ -343,16 +395,20 @@ async function refreshAccessToken(auth) {
343
395
  statusText: "Bad Request"
344
396
  })
345
397
  };
346
- try {
398
+ const candidateIds = options?.clientId ? [options.clientId] : Array.from(new Set([AGY_CLIENT_ID, process.env.AGY_CLIENT_ID].filter((id) => Boolean(id && id.length > 0))));
399
+ let revokedCount = 0;
400
+ let transientFailure;
401
+ for (const currentClientId of candidateIds) try {
347
402
  const startTime = Date.now();
403
+ const { clientId, clientSecret } = resolveAgyClientCredentials(currentClientId);
348
404
  const response = await proxiedFetch(OAUTH_TOKEN_URL, {
349
405
  method: "POST",
350
406
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
351
407
  body: new URLSearchParams({
352
408
  grant_type: "refresh_token",
353
409
  refresh_token: parts.refreshToken,
354
- client_id: AGY_CLIENT_ID,
355
- client_secret: AGY_CLIENT_SECRET
410
+ client_id: clientId,
411
+ client_secret: clientSecret
356
412
  })
357
413
  });
358
414
  if (!response.ok) {
@@ -361,8 +417,11 @@ async function refreshAccessToken(auth) {
361
417
  const details = [code, description ?? errorText].filter(Boolean).join(": ");
362
418
  const baseMessage = `Agy token refresh failed (${response.status} ${response.statusText})`;
363
419
  const message = details ? `${baseMessage} - ${details}` : baseMessage;
364
- if (code === "invalid_grant") return { type: "revoked" };
365
- return {
420
+ if (code === "invalid_grant") {
421
+ revokedCount++;
422
+ continue;
423
+ }
424
+ transientFailure = {
366
425
  type: "failed",
367
426
  error: new AgyTokenRefreshError({
368
427
  message,
@@ -372,6 +431,7 @@ async function refreshAccessToken(auth) {
372
431
  statusText: response.statusText
373
432
  })
374
433
  };
434
+ continue;
375
435
  }
376
436
  const payload = await response.json();
377
437
  const refreshedParts = {
@@ -385,421 +445,324 @@ async function refreshAccessToken(auth) {
385
445
  access: payload.access_token,
386
446
  expires: calculateTokenExpiry(startTime, payload.expires_in),
387
447
  refresh: formatRefreshParts(refreshedParts)
388
- }
448
+ },
449
+ clientId
389
450
  };
390
451
  } catch (error) {
391
- if (error instanceof AgyTokenRefreshError) throw error;
392
- return {
452
+ transientFailure = {
393
453
  type: "failed",
394
454
  error: new AgyTokenRefreshError({
395
455
  message: error instanceof Error ? error.message : "Unknown refresh error",
396
456
  status: 0,
397
- statusText: ""
457
+ statusText: "Network Error"
398
458
  })
399
459
  };
400
460
  }
401
- }
402
- //#endregion
403
- //#region src/store/keyring.ts
404
- /**
405
- * Master-key management and AES-256-GCM secret codec for the account store.
406
- *
407
- * The master key lives in the DSH credentials document (`~/.dsh/.credentials.yaml`,
408
- * 0600) under `AGY_MASTER_KEY` so both the in-harness plugin (via
409
- * `ctx.credentials`) and the standalone `dsh-agy` CLI (direct file read) can
410
- * encrypt and decrypt the same account store.
411
- */
412
- const MASTER_KEY_REF = "AGY_MASTER_KEY";
413
- const ENC_PREFIX$1 = "enc:v1:";
414
- /** AES-256-GCM codec; ciphertext format `enc:v1:<iv-b64>:<tag-b64>:<data-b64>`. */
415
- function createAesGcmCodec(key) {
416
- if (key.length !== 32) throw new Error(`createAesGcmCodec: master key must be 32 bytes, got ${key.length}`);
461
+ if (transientFailure) return transientFailure;
462
+ if (revokedCount === candidateIds.length) return { type: "revoked" };
417
463
  return {
418
- encrypt(plaintext) {
419
- const iv = randomBytes(12);
420
- const cipher = createCipheriv("aes-256-gcm", key, iv);
421
- const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
422
- const tag = cipher.getAuthTag();
423
- return `${ENC_PREFIX$1}${iv.toString("base64url")}:${tag.toString("base64url")}:${data.toString("base64url")}`;
424
- },
425
- decrypt(payload) {
426
- if (!payload.startsWith(ENC_PREFIX$1)) throw new Error("decrypt: payload is not in encrypted format");
427
- const [, , ivB64 = "", tagB64 = "", dataB64 = ""] = payload.split(":");
428
- const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
429
- decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
430
- return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64url")), decipher.final()]).toString("utf8");
431
- }
464
+ type: "failed",
465
+ error: new AgyTokenRefreshError({
466
+ message: "Token refresh failed on all candidate client IDs",
467
+ status: 400,
468
+ statusText: "Bad Request"
469
+ })
432
470
  };
433
471
  }
434
- /** Derive a 32-byte key from an arbitrary master-key string (SHA-256). */
435
- function deriveKey(masterKey) {
436
- return createHash("sha256").update(masterKey, "utf8").digest();
472
+ //#endregion
473
+ //#region src/runtime/rotation.ts
474
+ const BACKOFF_TIERS_MS = [
475
+ 5e3,
476
+ 1e4,
477
+ 2e4,
478
+ 3e4,
479
+ 6e4
480
+ ];
481
+ /** Below this remaining fraction the account is treated as soft-quota-exhausted. */
482
+ const SOFT_QUOTA_THRESHOLD = .15;
483
+ function backoffFor(consecutiveFailures, maxJitterMs = 1e3) {
484
+ const index = Math.min(Math.max(consecutiveFailures, 0), BACKOFF_TIERS_MS.length - 1);
485
+ return (BACKOFF_TIERS_MS[index] ?? BACKOFF_TIERS_MS[BACKOFF_TIERS_MS.length - 1]) + Math.floor(Math.random() * maxJitterMs);
437
486
  }
438
- /** Default DSH home (`~/.dsh`), honoring `$DSH_HOME`. */
439
- function resolveDshHome() {
440
- return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
487
+ /** Whether the account is currently in a cooldown window. */
488
+ function isCoolingDown(account, now = Date.now()) {
489
+ return (account.coolingDownUntil ?? 0) > now;
441
490
  }
442
- function homedir() {
443
- return process.env.HOME ?? process.env.USERPROFILE ?? ".";
491
+ /** Whether the requested model family on this account is rate-limited. */
492
+ function isFamilyRateLimited(account, family, now = Date.now()) {
493
+ if (!family) return false;
494
+ const resetAt = account.rateLimitResetTimes?.[family];
495
+ return typeof resetAt === "number" && resetAt > now;
496
+ }
497
+ /** Record a rate-limit reset for one model key, retaining the latest reset time. */
498
+ function recordRateLimit(account, modelKey, resetAtMs) {
499
+ const current = account.rateLimitResetTimes?.[modelKey] ?? 0;
500
+ account.rateLimitResetTimes = {
501
+ ...account.rateLimitResetTimes ?? {},
502
+ [modelKey]: Math.max(current, resetAtMs)
503
+ };
504
+ }
505
+ /** Clear expired rate limits and cooldowns in place. */
506
+ function clearExpiredState(account, now = Date.now()) {
507
+ if (account.rateLimitResetTimes) {
508
+ const fresh = Object.fromEntries(Object.entries(account.rateLimitResetTimes).filter(([, reset]) => reset > now));
509
+ account.rateLimitResetTimes = Object.keys(fresh).length > 0 ? fresh : void 0;
510
+ }
511
+ if (account.coolingDownUntil && account.coolingDownUntil <= now) {
512
+ account.coolingDownUntil = void 0;
513
+ account.cooldownReason = void 0;
514
+ }
515
+ }
516
+ /** 24h cooldown for a fully exhausted daily quota (single-account: stop hitting the wall). */
517
+ const FULL_QUOTA_COOLDOWN_MS = 864e5;
518
+ /** Cap for a server-reported reset time on per-minute limits (guards against bogus far-future values). */
519
+ const MAX_RATE_LIMIT_COOLDOWN_MS = 18e5;
520
+ /** Absolute server-reported reset in ms when it lies in the future, else undefined. */
521
+ function parseFutureResetMs$1(resetTime, now = Date.now()) {
522
+ if (!resetTime) return void 0;
523
+ const reset = Date.parse(resetTime);
524
+ if (Number.isNaN(reset) || reset <= now) return void 0;
525
+ return reset;
444
526
  }
445
527
  /**
446
- * Minimal reader for the DSH credentials document: a flat YAML mapping of
447
- * `KEY: "value"` / `KEY: value` lines. Values are unquoted with JSON-style
448
- * escaping for double-quoted scalars; anything more complex fails loudly.
528
+ * Decide what to do after one failed attempt.
529
+ * @param kind - classified failure kind.
530
+ * @param category - 429 sub-category when kind is rate-limit.
531
+ * @param account - the account that failed (mutated with cooldown/rate-limit state).
532
+ * @param consecutiveFailures - consecutive failures on this account.
533
+ * @param retryAfterMs - server-provided retry delay when present.
534
+ * @param resetTime - server-provided absolute reset time; cooldowns use it (capped) instead of fixed windows.
449
535
  */
450
- function readCredentialsDocument(file) {
451
- if (!existsSync(file)) return /* @__PURE__ */ new Map();
452
- const entries = /* @__PURE__ */ new Map();
453
- const text = readFileSync(file, "utf8");
454
- for (const line of text.split(/\r?\n/)) {
455
- const trimmed = line.trim();
456
- if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("---")) continue;
457
- const match = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(trimmed);
458
- if (!match) continue;
459
- const [, key = "", rawValue = ""] = match;
460
- let value;
461
- if (rawValue.startsWith("\"")) try {
462
- value = JSON.parse(rawValue);
463
- } catch {
464
- throw new Error(`readCredentialsDocument: invalid quoted value for "${key}" in ${file}`);
536
+ function decideRotation(kind, account, consecutiveFailures, retryAfterMs, category = "unknown", resetTime) {
537
+ const now = Date.now();
538
+ const backoffMs = backoffFor(consecutiveFailures);
539
+ switch (kind) {
540
+ case "rate-limit": {
541
+ if (category === "soft_rate_limit") return {
542
+ action: "retry",
543
+ backoffMs: Math.min(retryAfterMs ?? backoffMs, 3e3)
544
+ };
545
+ if (category === "quota_exhausted") {
546
+ const resetMs = parseFutureResetMs$1(resetTime, now);
547
+ const cooldownMs = resetMs !== void 0 ? Math.min(resetMs - now, FULL_QUOTA_COOLDOWN_MS) : FULL_QUOTA_COOLDOWN_MS;
548
+ account.coolingDownUntil = now + Math.max(cooldownMs, 6e4);
549
+ account.cooldownReason = "quota-exhausted";
550
+ return {
551
+ action: "cool",
552
+ backoffMs: Math.max(cooldownMs, 6e4)
553
+ };
554
+ }
555
+ const resetMs = parseFutureResetMs$1(resetTime, now);
556
+ const cooldownMs = resetMs !== void 0 ? Math.min(resetMs - now, MAX_RATE_LIMIT_COOLDOWN_MS) : retryAfterMs ?? 3e5;
557
+ return {
558
+ action: "rotate",
559
+ backoffMs: Math.max(cooldownMs, 1e3)
560
+ };
465
561
  }
466
- else if (rawValue.startsWith("'")) {
467
- if (!rawValue.endsWith("'") || rawValue.length < 2) throw new Error(`readCredentialsDocument: unterminated single-quoted value for "${key}" in ${file}`);
468
- value = rawValue.slice(1, -1).replace(/''/g, "'");
469
- } else value = rawValue;
470
- if (value.length === 0) continue;
471
- entries.set(key, value);
562
+ case "auth-failure":
563
+ account.verificationRequired = true;
564
+ account.verificationRequiredAt = now;
565
+ account.verificationRequiredReason = "auth-failure";
566
+ account.enabled = false;
567
+ return { action: "revoke" };
568
+ case "network-error":
569
+ account.coolingDownUntil = now + backoffMs;
570
+ account.cooldownReason = "network-error";
571
+ return {
572
+ action: "rotate",
573
+ backoffMs
574
+ };
575
+ case "project-error":
576
+ account.coolingDownUntil = now + backoffMs;
577
+ account.cooldownReason = "project-error";
578
+ return {
579
+ action: "cool",
580
+ backoffMs
581
+ };
582
+ case "request-error": return { action: "noop" };
583
+ case "transient": return {
584
+ action: "retry",
585
+ backoffMs
586
+ };
472
587
  }
473
- return entries;
474
588
  }
475
589
  /**
476
- * POSIX owner-only enforcement. Windows mode bits never report 0600 (and
477
- * chmod is a no-op there), so the check is skipped on win32; the encrypted
478
- * account file and credentials document remain the only defense-in-depth
479
- * layer on that platform.
590
+ * Pick the next account index for rotation (round-robin across enabled,
591
+ * non-cooling accounts; falls back to the active one when all are cooling).
480
592
  */
481
- function assertOwnerOnly(file) {
482
- if (process.platform === "win32") return;
483
- const mode = statSync(file).mode & 511;
484
- if (mode !== 384) throw new Error(`dsh-agy: ${file} is readable beyond its owner (mode ${mode.toString(8)}); run "chmod 600" before starting again`);
593
+ function pickNextAccountIndex(accounts, currentIndex, now = Date.now(), modelOrFamily) {
594
+ if (accounts.length <= 1) return currentIndex;
595
+ const enabled = accounts.map((a, i) => ({
596
+ account: a,
597
+ index: i
598
+ })).filter(({ account, index }) => {
599
+ if (index === currentIndex || account.enabled === false) return false;
600
+ if (isCoolingDown(account, now)) return false;
601
+ if (modelOrFamily && isFamilyRateLimited(account, modelOrFamily, now)) return false;
602
+ return true;
603
+ });
604
+ if (enabled.length === 0) return currentIndex;
605
+ return (enabled.find((e) => e.index > currentIndex) ?? enabled[0]).index;
485
606
  }
486
- /**
487
- * Load the master key from the DSH credentials document. Returns undefined when
488
- * the document or the reference is absent.
489
- */
490
- function loadMasterKey(dshHome) {
491
- const file = join(dshHome, ".credentials.yaml");
492
- if (!existsSync(file)) return void 0;
493
- assertOwnerOnly(file);
494
- return readCredentialsDocument(file).get(MASTER_KEY_REF);
607
+ /** Build the soft-quota cache TTL: short when low, long when healthy. */
608
+ function computeSoftQuotaCacheTtlMs(remainingFraction, now = Date.now()) {
609
+ if (typeof remainingFraction !== "number") return 6e5;
610
+ if (remainingFraction < .15) return 6e4;
611
+ if (remainingFraction < .5) return 3e5;
612
+ return 9e5;
613
+ }
614
+ const DAY_MS = 864e5;
615
+ /** Floor for remaining-time in drain-urgency scores (mirrors AuthStorage; a stale reset must not explode the score). */
616
+ const DRAIN_FLOOR_MS = 6e4;
617
+ /** Map a model id to its backend quota counter family (OMP getAntigravityCounterKeyForModel). */
618
+ function modelFamilyOf(modelId) {
619
+ if (!modelId) return void 0;
620
+ const id = modelId.toLowerCase();
621
+ if (id.startsWith("claude-")) return "anthropic";
622
+ if (id.startsWith("gemini-") || id.startsWith("gemma-")) return "google";
623
+ if (id.startsWith("gpt-") || id.startsWith("openai/")) return "openai";
624
+ }
625
+ /** Quota-cache key for a request: the model's family, or the unknown bucket. */
626
+ function familyKeyOf(modelId) {
627
+ return modelFamilyOf(modelId) ?? "unknown";
628
+ }
629
+ function earliestResetTime(a, b) {
630
+ if (!a) return b;
631
+ if (!b) return a;
632
+ const ta = Date.parse(a);
633
+ const tb = Date.parse(b);
634
+ if (Number.isNaN(ta)) return b;
635
+ if (Number.isNaN(tb)) return a;
636
+ return ta <= tb ? a : b;
495
637
  }
496
638
  /**
497
- * Generate and persist a fresh master key (0600) in the DSH credentials document.
498
- *
499
- * Append-only + atomic rename: the DSH credentials service owns this file and
500
- * preserves comments and the formatting of untouched entries (and may hold
501
- * YAML constructs this minimal reader cannot parse). Rewriting the whole file
502
- * from our parsed view would silently drop those; appending keeps them.
639
+ * Aggregate a fetchAvailableModels response into per-family quota records:
640
+ * the family's remaining fraction is its most-pressured model's, and the
641
+ * reset time is the earliest across the family (the bottleneck resets first).
503
642
  */
504
- function persistMasterKey(dshHome, masterKey) {
505
- const file = join(dshHome, ".credentials.yaml");
506
- mkdirSync(dirname(file), { recursive: true });
507
- const existingText = existsSync(file) ? readFileSync(file, "utf8") : "";
508
- if (existingText.length > 0 && readCredentialsDocument(file).has("AGY_MASTER_KEY")) throw new Error(`persistMasterKey: ${MASTER_KEY_REF} already exists in ${file}`);
509
- const entry = `${MASTER_KEY_REF}: ${JSON.stringify(masterKey)}`;
510
- const next = existingText.length > 0 && !existingText.endsWith("\n") ? `${existingText}\n${entry}\n` : `${existingText}${entry}\n`;
511
- const tmp = `${file}.tmp-masterkey`;
512
- writeFileSync(tmp, next, { mode: 384 });
513
- renameSync(tmp, file);
643
+ function ingestFamilyQuotas(discovered) {
644
+ const families = /* @__PURE__ */ new Map();
645
+ for (const [modelId, entry] of Object.entries(discovered.models ?? {})) {
646
+ const remaining = entry.quotaInfo?.remainingFraction;
647
+ if (typeof remaining !== "number" || !Number.isFinite(remaining)) continue;
648
+ const key = familyKeyOf(modelId);
649
+ const current = families.get(key);
650
+ const resetTime = earliestResetTime(current?.resetTime, entry.quotaInfo?.resetTime);
651
+ families.set(key, {
652
+ remainingFraction: current ? Math.min(current.remainingFraction ?? 1, remaining) : remaining,
653
+ ...resetTime ? { resetTime } : {},
654
+ modelCount: (current?.modelCount ?? 0) + 1
655
+ });
656
+ }
657
+ return Object.fromEntries(families);
658
+ }
659
+ /** The quota record for one family, or the most-pressured family when the model is unknown. */
660
+ function familyQuotaFor(account, family) {
661
+ const cache = account.cachedQuota ?? {};
662
+ if (family) return cache[family];
663
+ let worst;
664
+ for (const entry of Object.values(cache)) {
665
+ if (typeof entry.remainingFraction !== "number") continue;
666
+ if (!worst || entry.remainingFraction < (worst.remainingFraction ?? 1)) worst = entry;
667
+ }
668
+ return worst;
669
+ }
670
+ /** Whether the account's quota cache needs a refresh (missing, or past its health-based TTL). */
671
+ function isQuotaStale(account, now = Date.now()) {
672
+ if (!account.cachedQuota || !account.cachedQuotaUpdatedAt) return true;
673
+ const ttl = computeSoftQuotaCacheTtlMs(familyQuotaFor(account)?.remainingFraction);
674
+ return now - account.cachedQuotaUpdatedAt > ttl;
675
+ }
676
+ /** Whether the requested family on this account is soft-quota-exhausted (below the pre-check threshold). */
677
+ function isFamilyDrained(account, family, now = Date.now()) {
678
+ const quota = familyQuotaFor(account, family);
679
+ if (!quota || typeof quota.remainingFraction !== "number") return false;
680
+ if (quota.resetTime) {
681
+ const reset = Date.parse(quota.resetTime);
682
+ if (!Number.isNaN(reset) && reset <= now) return false;
683
+ }
684
+ return quota.remainingFraction < SOFT_QUOTA_THRESHOLD;
514
685
  }
515
686
  /**
516
- * Resolve (load or create) the master key for a dsh home, then build the codec.
517
- * Creating a key writes the credentials document; read-only setups should call
518
- * {@link loadMasterKey} first and surface a friendly error instead.
687
+ * Required drain rate: headroomFraction / remainingHours — how fast the
688
+ * family's remaining quota must be consumed to avoid expiring unused at its
689
+ * reset (mirrors AuthStorage.#computeWindowRequiredDrain with a daily window).
519
690
  */
520
- function resolveMasterKeyCodec(dshHome) {
521
- let masterKey = loadMasterKey(dshHome);
522
- let created = false;
523
- if (!masterKey) {
524
- masterKey = randomBytes(32).toString("hex");
525
- persistMasterKey(dshHome, masterKey);
526
- created = true;
691
+ function requiredDrainFor(quota, now = Date.now()) {
692
+ const remaining = quota?.remainingFraction;
693
+ if (typeof remaining !== "number" || !Number.isFinite(remaining)) return 0;
694
+ const headroom = Math.min(Math.max(remaining, 0), 1);
695
+ if (headroom <= 0) return 0;
696
+ let remainingMs = DAY_MS;
697
+ const resetTime = quota?.resetTime;
698
+ if (resetTime) {
699
+ const resetAt = Date.parse(resetTime);
700
+ if (!Number.isNaN(resetAt)) remainingMs = Math.min(remainingMs, Math.max(resetAt - now, 0));
527
701
  }
528
- return {
529
- codec: createAesGcmCodec(deriveKey(masterKey)),
530
- created
531
- };
702
+ return headroom / (Math.max(remainingMs, DRAIN_FLOOR_MS) / 36e5);
532
703
  }
533
- const ENC_PREFIX = "enc:v1:";
534
- const properFileLock = { async withLock(file, fn) {
535
- const release = await lockfile.lock(file, {
536
- stale: 3e4,
537
- update: 1e4,
538
- retries: {
539
- retries: 10,
540
- factor: 1.5,
541
- minTimeout: 50,
542
- maxTimeout: 2e3
704
+ function parseFutureResetMs(resetTime, now) {
705
+ if (!resetTime) return void 0;
706
+ const reset = Date.parse(resetTime);
707
+ if (Number.isNaN(reset) || reset <= now) return void 0;
708
+ return reset;
709
+ }
710
+ /**
711
+ * Rank pool candidates for one request, mirroring AuthStorage's antigravity
712
+ * ordering: unblocked first (earliest unblock time among blocked), hot windows
713
+ * last, measured usage before unmeasured, required-drain descending, then
714
+ * used-fraction ascending. Ties preserve the rotation order seeded from
715
+ * `startIndex` so an unmeasured pool keeps the active-account bias.
716
+ */
717
+ function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0) {
718
+ const family = modelFamilyOf(modelId);
719
+ const activePos = entries.findIndex((e) => e.index === startIndex);
720
+ const clampedStart = activePos >= 0 ? activePos : 0;
721
+ const candidates = (entries.length === 0 ? [] : [...entries.slice(clampedStart), ...entries.slice(0, clampedStart)]).map(({ account, index }, orderPos) => {
722
+ const quota = familyQuotaFor(account, family);
723
+ const remaining = quota?.remainingFraction;
724
+ const used = typeof remaining === "number" ? Math.min(Math.max(1 - remaining, 0), 1) : void 0;
725
+ let blockedUntil = null;
726
+ if (account.coolingDownUntil && account.coolingDownUntil > now) blockedUntil = account.coolingDownUntil;
727
+ const familyLimit = account.rateLimitResetTimes?.[familyKeyOf(modelId)];
728
+ if (familyLimit !== void 0 && familyLimit > now) blockedUntil = blockedUntil === null ? familyLimit : Math.max(blockedUntil, familyLimit);
729
+ if (blockedUntil === null && quota && typeof remaining === "number" && remaining <= 0) {
730
+ const resetMs = parseFutureResetMs(quota.resetTime, now);
731
+ if (resetMs !== void 0) blockedUntil = resetMs;
543
732
  }
733
+ return {
734
+ account,
735
+ index,
736
+ orderPos,
737
+ blockedUntil,
738
+ usedFraction: used,
739
+ requiredDrain: requiredDrainFor(quota, now),
740
+ hot: used !== void 0 && used >= .85,
741
+ measured: used !== void 0
742
+ };
544
743
  });
545
- try {
546
- return await fn();
547
- } finally {
548
- await release();
549
- }
550
- } };
551
- function isEncrypted(value) {
552
- return value.startsWith(ENC_PREFIX);
553
- }
554
- /** Decrypt one account's refresh field when encrypted; plaintext passes through (legacy). */
555
- function decryptAccount(account, codec) {
556
- if (isEncrypted(account.refresh)) return {
557
- ...account,
558
- refresh: codec.decrypt(account.refresh)
559
- };
560
- return account;
561
- }
562
- /** Encrypt one account's refresh field (plaintext stays plaintext if no codec change). */
563
- function encryptAccount(account, codec) {
564
- if (!isEncrypted(account.refresh)) return {
565
- ...account,
566
- refresh: codec.encrypt(account.refresh)
567
- };
568
- return account;
569
- }
570
- function decryptStorage(storage, codec) {
571
- return {
572
- ...storage,
573
- accounts: storage.accounts.map((a) => decryptAccount(a, codec))
574
- };
575
- }
576
- function encryptStorage(storage, codec) {
577
- return {
578
- ...storage,
579
- accounts: storage.accounts.map((a) => encryptAccount(a, codec))
580
- };
581
- }
582
- function migrateV1ToV2(v1) {
583
- return {
584
- version: 2,
585
- accounts: v1.accounts.map((acc) => ({
586
- email: acc.email,
587
- refreshToken: acc.refreshToken,
588
- projectId: acc.projectId,
589
- managedProjectId: acc.managedProjectId,
590
- addedAt: acc.addedAt,
591
- lastUsed: acc.lastUsed,
592
- lastSwitchReason: acc.lastSwitchReason,
593
- rateLimitResetTimes: acc.isRateLimited && acc.rateLimitResetTime ? { default: acc.rateLimitResetTime } : void 0
594
- })),
595
- activeIndex: v1.activeIndex
596
- };
597
- }
598
- function migrateV2ToV3(v2) {
599
- return {
600
- version: 3,
601
- accounts: v2.accounts.map((acc) => ({
602
- email: acc.email,
603
- refresh: `${acc.refreshToken}|${acc.projectId ?? ""}|${acc.managedProjectId ?? ""}`,
604
- projectId: acc.projectId,
605
- managedProjectId: acc.managedProjectId,
606
- addedAt: acc.addedAt,
607
- lastUsed: acc.lastUsed,
608
- enabled: true,
609
- lastSwitchReason: acc.lastSwitchReason,
610
- rateLimitResetTimes: acc.rateLimitResetTimes
611
- })),
612
- activeIndex: v2.activeIndex
613
- };
614
- }
615
- function migrateV3ToV4(v3) {
616
- return {
617
- version: 4,
618
- accounts: v3.accounts,
619
- activeIndex: v3.activeIndex
620
- };
621
- }
622
- function migrateStorage(raw) {
623
- switch (raw.version) {
624
- case 1: return migrateStorage(migrateV1ToV2(raw));
625
- case 2: return migrateStorage(migrateV2ToV3(raw));
626
- case 3: return migrateStorage(migrateV3ToV4(raw));
627
- case 4: return raw;
628
- default: throw new Error(`migrateStorage: unsupported storage version ${raw.version}`);
629
- }
630
- }
631
- var JsonAccountStore = class {
632
- file;
633
- codec;
634
- lock;
635
- constructor(options) {
636
- this.file = options.file;
637
- this.codec = options.codec;
638
- this.lock = options.lock ?? properFileLock;
639
- }
640
- /**
641
- * Ensure the store document exists before locking: proper-lockfile resolves
642
- * the target's realpath and fails with ENOENT when it is absent.
643
- */
644
- ensureFile() {
645
- if (existsSync(this.file)) return;
646
- mkdirSync(dirname(this.file), { recursive: true });
647
- const tmp = `${this.file}.tmp-init`;
648
- writeFileSync(tmp, JSON.stringify({
649
- version: 4,
650
- accounts: [],
651
- activeIndex: 0
652
- }) + "\n", { mode: 384 });
653
- renameSync(tmp, this.file);
654
- }
655
- async load() {
656
- let text;
657
- try {
658
- text = readFileSync(this.file, "utf8");
659
- } catch (error) {
660
- if (error.code === "ENOENT") return {
661
- version: 4,
662
- accounts: [],
663
- activeIndex: 0
664
- };
665
- throw error;
666
- }
667
- assertOwnerOnly(this.file);
668
- return decryptStorage(migrateStorage(JSON.parse(text)), this.codec);
669
- }
670
- async save(storage) {
671
- const encrypted = encryptStorage(storage, this.codec);
672
- mkdirSync(dirname(this.file), { recursive: true });
673
- const tmp = `${this.file}.tmp`;
674
- writeFileSync(tmp, JSON.stringify(encrypted, null, 2) + "\n", { mode: 384 });
675
- renameSync(tmp, this.file);
676
- }
677
- async mutate(fn) {
678
- this.ensureFile();
679
- return this.lock.withLock(this.file, async () => {
680
- const storage = await this.load();
681
- const result = await fn(storage);
682
- await this.save(storage);
683
- return result;
684
- });
685
- }
686
- };
687
- /** Pick the active account, falling back to the first enabled one. */
688
- function resolveActiveAccount(storage) {
689
- const accounts = storage.accounts;
690
- if (accounts.length === 0) return void 0;
691
- const active = accounts[storage.activeIndex];
692
- if (active && active.enabled !== false) return {
693
- account: active,
694
- index: storage.activeIndex
695
- };
696
- const fallbackIndex = accounts.findIndex((a) => a.enabled !== false);
697
- if (fallbackIndex === -1) return void 0;
698
- return {
699
- account: accounts[fallbackIndex],
700
- index: fallbackIndex
701
- };
702
- }
703
- //#endregion
704
- //#region src/runtime/rotation.ts
705
- const BACKOFF_TIERS_MS = [
706
- 5e3,
707
- 1e4,
708
- 2e4,
709
- 3e4,
710
- 6e4
711
- ];
712
- function backoffFor(consecutiveFailures, maxJitterMs = 1e3) {
713
- const index = Math.min(Math.max(consecutiveFailures, 0), BACKOFF_TIERS_MS.length - 1);
714
- return (BACKOFF_TIERS_MS[index] ?? BACKOFF_TIERS_MS[BACKOFF_TIERS_MS.length - 1]) + Math.floor(Math.random() * maxJitterMs);
715
- }
716
- /** Whether the account is currently in a cooldown window. */
717
- function isCoolingDown(account, now = Date.now()) {
718
- return (account.coolingDownUntil ?? 0) > now;
719
- }
720
- /** Clear expired rate limits and cooldowns in place. */
721
- function clearExpiredState(account, now = Date.now()) {
722
- if (account.rateLimitResetTimes) {
723
- const fresh = Object.fromEntries(Object.entries(account.rateLimitResetTimes).filter(([, reset]) => reset > now));
724
- account.rateLimitResetTimes = Object.keys(fresh).length > 0 ? fresh : void 0;
725
- }
726
- if (account.coolingDownUntil && account.coolingDownUntil <= now) {
727
- account.coolingDownUntil = void 0;
728
- account.cooldownReason = void 0;
729
- }
730
- }
731
- /** 24h cooldown for a fully exhausted daily quota (single-account: stop hitting the wall). */
732
- const FULL_QUOTA_COOLDOWN_MS = 864e5;
733
- /**
734
- * Decide what to do after one failed attempt.
735
- * @param kind - classified failure kind.
736
- * @param category - 429 sub-category when kind is rate-limit.
737
- * @param account - the account that failed (mutated with cooldown/rate-limit state).
738
- * @param consecutiveFailures - consecutive failures on this account.
739
- * @param retryAfterMs - server-provided retry delay when present.
740
- */
741
- function decideRotation(kind, account, consecutiveFailures, retryAfterMs, category = "unknown") {
742
- const now = Date.now();
743
- const backoffMs = backoffFor(consecutiveFailures);
744
- switch (kind) {
745
- case "rate-limit":
746
- if (category === "soft_rate_limit") return {
747
- action: "retry",
748
- backoffMs: Math.min(retryAfterMs ?? backoffMs, 3e3)
749
- };
750
- if (category === "quota_exhausted") {
751
- account.coolingDownUntil = now + FULL_QUOTA_COOLDOWN_MS;
752
- account.cooldownReason = void 0;
753
- return {
754
- action: "cool",
755
- backoffMs: FULL_QUOTA_COOLDOWN_MS
756
- };
757
- }
758
- account.coolingDownUntil = now + (retryAfterMs ?? 3e5);
759
- account.cooldownReason = void 0;
760
- return {
761
- action: "rotate",
762
- backoffMs
763
- };
764
- case "auth-failure":
765
- account.verificationRequired = true;
766
- account.verificationRequiredAt = now;
767
- account.verificationRequiredReason = "auth-failure";
768
- account.enabled = false;
769
- return { action: "revoke" };
770
- case "network-error":
771
- account.coolingDownUntil = now + backoffMs;
772
- account.cooldownReason = "network-error";
773
- return {
774
- action: "rotate",
775
- backoffMs
776
- };
777
- case "project-error":
778
- account.coolingDownUntil = now + backoffMs;
779
- account.cooldownReason = "project-error";
780
- return {
781
- action: "cool",
782
- backoffMs
783
- };
784
- case "request-error": return { action: "noop" };
785
- case "transient": return {
786
- action: "retry",
787
- backoffMs
788
- };
789
- }
790
- }
791
- /**
792
- * Pick the next account index for rotation (round-robin across enabled,
793
- * non-cooling accounts; falls back to the active one when all are cooling).
794
- */
795
- function pickNextAccountIndex(accounts, currentIndex, now = Date.now()) {
796
- if (accounts.length <= 1) return currentIndex;
797
- const enabled = accounts.map((a, i) => ({
798
- account: a,
799
- index: i
800
- })).filter(({ account, index }) => index !== currentIndex && account.enabled !== false && !isCoolingDown(account, now));
801
- if (enabled.length === 0) return currentIndex;
802
- return (enabled.find((e) => e.index > currentIndex) ?? enabled[0]).index;
744
+ candidates.sort((left, right) => {
745
+ const leftBlocked = left.blockedUntil !== null;
746
+ const rightBlocked = right.blockedUntil !== null;
747
+ if (leftBlocked !== rightBlocked) return leftBlocked ? 1 : -1;
748
+ if (leftBlocked && rightBlocked) return (left.blockedUntil ?? 0) - (right.blockedUntil ?? 0);
749
+ if (left.hot !== right.hot) return left.hot ? 1 : -1;
750
+ if (left.measured !== right.measured) return left.measured ? -1 : 1;
751
+ const drain = right.requiredDrain - left.requiredDrain;
752
+ if (drain !== 0) return drain;
753
+ const usedDiff = (left.usedFraction ?? .5) - (right.usedFraction ?? .5);
754
+ if (usedDiff !== 0) return usedDiff;
755
+ return left.orderPos - right.orderPos;
756
+ });
757
+ return candidates.map(({ account, index, blockedUntil, usedFraction, requiredDrain, hot, measured }) => ({
758
+ account,
759
+ index,
760
+ blockedUntil,
761
+ usedFraction,
762
+ requiredDrain,
763
+ hot,
764
+ measured
765
+ }));
803
766
  }
804
767
  //#endregion
805
768
  //#region src/runtime/fingerprint.ts
@@ -909,6 +872,19 @@ function getRandomizedHeaders(data = getFingerprintData(), version = randomFrom(
909
872
  "Client-Metadata": JSON.stringify({ ideType: randomFrom(data.ideTypes) })
910
873
  };
911
874
  }
875
+ /**
876
+ * Deterministic fallback headers for the `stable` fingerprint mode: the first
877
+ * entry of each pool, every call — one fixed client identity instead of
878
+ * per-request randomization (OMP-style fixed-client posture).
879
+ */
880
+ function getStableHeaders(data = getFingerprintData(), version = data.versionPool[0] ?? "") {
881
+ const platform = data.platforms[0] ?? "windows/amd64";
882
+ return {
883
+ "User-Agent": `antigravity/${version || "1.18.3"} ${platform}`,
884
+ "X-Goog-Api-Client": data.sdkClients[0] ?? "",
885
+ "Client-Metadata": JSON.stringify({ ideType: data.ideTypes[0] ?? "ANTIGRAVITY" })
886
+ };
887
+ }
912
888
  /** Rewrite the version inside a fingerprint UA; reports whether it changed. */
913
889
  function updateFingerprintVersion(fingerprint, version) {
914
890
  const pattern = /^(antigravity\/)([\d.]+)/;
@@ -925,10 +901,35 @@ function recordFingerprintVersion(history, fingerprint, reason) {
925
901
  reason
926
902
  }].slice(-5);
927
903
  }
904
+ //#endregion
905
+ //#region src/runtime/risk.ts
906
+ const TRUE_VALUES = /* @__PURE__ */ new Set([
907
+ "1",
908
+ "true",
909
+ "yes",
910
+ "on"
911
+ ]);
912
+ function envFlag(name) {
913
+ const value = process.env[name];
914
+ return value !== void 0 && TRUE_VALUES.has(value.trim().toLowerCase());
915
+ }
916
+ /** Global kill switch: DSH_AGY_DISABLE=1 keeps the plugin from registering anything. */
917
+ function isAgyDisabled() {
918
+ return envFlag("DSH_AGY_DISABLE");
919
+ }
920
+ /**
921
+ * Fingerprint strategy: `dynamic` (default, upstream behavior: per-request
922
+ * header randomization + regeneration on repeated rate-limits) or `stable`
923
+ * (one identity per account, never regenerated, deterministic fallback
924
+ * headers — mirrors OMP's fixed-client posture).
925
+ */
926
+ function fingerprintMode() {
927
+ return process.env.DSH_AGY_FINGERPRINT_MODE === "stable" ? "stable" : "dynamic";
928
+ }
928
929
  /**
929
930
  * Resolve the impersonation headers for one request from the account's
930
- * persistent fingerprint (stable identity), falling back to full per-request
931
- * randomization when the account has no fingerprint yet.
931
+ * persistent fingerprint (stable identity). The fallback randomizes per
932
+ * request in `dynamic` mode and pins one identity in `stable` mode.
932
933
  */
933
934
  function impersonationHeadersFor(account) {
934
935
  const fingerprint = account.fingerprint;
@@ -937,22 +938,29 @@ function impersonationHeadersFor(account) {
937
938
  "X-Goog-Api-Client": fingerprint.apiClient,
938
939
  "Client-Metadata": JSON.stringify(fingerprint.clientMetadata)
939
940
  };
940
- const randomized = getRandomizedHeaders(DEFAULT_FINGERPRINT_DATA);
941
+ const headers = fingerprintMode() === "stable" ? getStableHeaders(DEFAULT_FINGERPRINT_DATA) : getRandomizedHeaders(DEFAULT_FINGERPRINT_DATA);
941
942
  return {
942
- "User-Agent": randomized["User-Agent"],
943
- "X-Goog-Api-Client": randomized["X-Goog-Api-Client"],
944
- "Client-Metadata": randomized["Client-Metadata"]
943
+ "User-Agent": headers["User-Agent"],
944
+ "X-Goog-Api-Client": headers["X-Goog-Api-Client"],
945
+ "Client-Metadata": headers["Client-Metadata"]
945
946
  };
946
947
  }
947
- var AgySessionManager = class {
948
+ var AgySessionManager = class AgySessionManager {
948
949
  store;
949
950
  onRotate;
951
+ onHealthReport;
950
952
  tokenCache = /* @__PURE__ */ new Map();
951
953
  /** In-flight refresh promises keyed by account: concurrent requests share one refresh. */
952
954
  refreshInFlight = /* @__PURE__ */ new Map();
955
+ /** In-flight quota fetches keyed by account: concurrent selections share one fetchAvailableModels call. */
956
+ quotaRefreshInFlight = /* @__PURE__ */ new Map();
953
957
  failureCounts = /* @__PURE__ */ new Map();
954
958
  /** Accounts whose request-time project discovery already failed (no retry per request). */
955
959
  projectRetryFailed = /* @__PURE__ */ new Set();
960
+ /** Bound for one quota poll so selection never stalls on a hung endpoint. */
961
+ static QUOTA_FETCH_TIMEOUT_MS = 3e3;
962
+ /** Refresh the token this far ahead of expiry so a request never blocks on the token endpoint. */
963
+ static REFRESH_SKEW_MS = 12e4;
956
964
  /**
957
965
  * Session affinity (time-window approximation): DSH exposes no conversation
958
966
  * id, so instead of pinning per session we reuse the last-used account while
@@ -964,23 +972,18 @@ var AgySessionManager = class {
964
972
  constructor(options) {
965
973
  this.store = options.store;
966
974
  this.onRotate = options.onRotate;
975
+ this.onHealthReport = options.onHealthReport;
967
976
  }
968
977
  accountKey(account) {
969
- return account.email ?? `idx-${account.refresh}`;
978
+ return account.id ?? account.email ?? `idx-${account.refresh}`;
970
979
  }
971
- /** Resolve a usable access token for the account, refreshing when expired. */
972
- async accessTokenFor(account) {
973
- const key = this.accountKey(account);
974
- const cached = this.tokenCache.get(key);
975
- if (cached && !accessTokenExpired({
976
- access: cached.access,
977
- expires: cached.expires,
978
- refresh: account.refresh
979
- })) return {
980
- access: cached.access,
981
- expires: cached.expires,
982
- refresh: account.refresh
983
- };
980
+ /**
981
+ * Refresh the account's access token (single-flight per account). A transient
982
+ * refresh failure keeps the cached token in place (retain-last-good): the old
983
+ * token stays valid until its own expiry and a later request retries the
984
+ * refresh. Only `invalid_grant` drops the cache.
985
+ */
986
+ refreshToken(key, account, cached) {
984
987
  const inFlight = this.refreshInFlight.get(key);
985
988
  if (inFlight) return inFlight;
986
989
  const refreshing = (async () => {
@@ -988,130 +991,287 @@ var AgySessionManager = class {
988
991
  access: cached?.access ?? "",
989
992
  expires: cached?.expires ?? 0,
990
993
  refresh: account.refresh
991
- });
994
+ }, { clientId: account.clientId });
992
995
  if (result.type === "success") {
996
+ if (!account.clientId && result.clientId) {
997
+ await this.store.mutate((s) => {
998
+ const target = s.accounts.find((candidate) => this.accountKey(candidate) === key);
999
+ if (!target) throw new Error(`Account ${key} not found during clientId migration`);
1000
+ target.clientId = result.clientId;
1001
+ });
1002
+ account.clientId = result.clientId;
1003
+ }
993
1004
  this.tokenCache.set(key, {
994
1005
  access: result.auth.access,
995
1006
  expires: result.auth.expires
996
1007
  });
997
1008
  return result.auth;
998
1009
  }
1010
+ if (result.type === "failed") {
1011
+ if (cached && !accessTokenExpired({
1012
+ access: cached.access,
1013
+ expires: cached.expires,
1014
+ refresh: account.refresh
1015
+ })) return {
1016
+ access: cached.access,
1017
+ expires: cached.expires,
1018
+ refresh: account.refresh
1019
+ };
1020
+ throw new AgyAuthError(result.error.status === 429 ? "rate-limit" : result.error.status === 0 || result.error.status === 408 || result.error.status >= 500 ? "transport" : "invalid-credential", result.error.message, { cause: result.error });
1021
+ }
999
1022
  if (result.type === "revoked") {
1000
1023
  this.tokenCache.delete(key);
1024
+ await this.store.mutate((s) => {
1025
+ const target = s.accounts.find((candidate) => this.accountKey(candidate) === key);
1026
+ if (target) {
1027
+ target.enabled = false;
1028
+ target.verificationRequired = true;
1029
+ target.verificationRequiredAt = Date.now();
1030
+ target.verificationRequiredReason = "auth-failure";
1031
+ }
1032
+ });
1033
+ account.enabled = false;
1034
+ account.verificationRequired = true;
1035
+ account.verificationRequiredAt = Date.now();
1036
+ account.verificationRequiredReason = "auth-failure";
1001
1037
  return;
1002
1038
  }
1003
1039
  })();
1004
1040
  this.refreshInFlight.set(key, refreshing);
1005
- try {
1006
- return await refreshing;
1007
- } finally {
1008
- this.refreshInFlight.delete(key);
1009
- }
1041
+ refreshing.then(() => this.refreshInFlight.delete(key), () => this.refreshInFlight.delete(key));
1042
+ return refreshing;
1010
1043
  }
1011
- async pickAccount(storage) {
1044
+ /** Resolve a usable access token for the account, pre-emptively refreshing near expiry. */
1045
+ async accessTokenFor(account) {
1046
+ const key = this.accountKey(account);
1047
+ const cached = this.tokenCache.get(key);
1048
+ const now = Date.now();
1049
+ if (cached && !accessTokenExpired({
1050
+ access: cached.access,
1051
+ expires: cached.expires,
1052
+ refresh: account.refresh
1053
+ })) {
1054
+ if (cached.expires <= now + AgySessionManager.REFRESH_SKEW_MS) this.refreshToken(key, account, cached);
1055
+ return {
1056
+ access: cached.access,
1057
+ expires: cached.expires,
1058
+ refresh: account.refresh
1059
+ };
1060
+ }
1061
+ return this.refreshToken(key, account, cached);
1062
+ }
1063
+ /**
1064
+ * Refresh stale per-account quota caches (family-scoped, health-based TTL).
1065
+ * Failures leave the account unmeasured: ranking treats it as a fallback
1066
+ * instead of blocking selection on a hung endpoint.
1067
+ */
1068
+ async refreshQuotaCache(storage) {
1069
+ const now = Date.now();
1070
+ const stale = storage.accounts.filter((account) => account.enabled !== false && isQuotaStale(account, now));
1071
+ if (stale.length === 0) return;
1072
+ const updates = (await Promise.all(stale.map(async (account) => {
1073
+ const key = this.accountKey(account);
1074
+ if (this.quotaRefreshInFlight.has(key)) return this.quotaRefreshInFlight.get(key);
1075
+ const refresh = (async () => {
1076
+ try {
1077
+ const auth = await this.accessTokenFor(account);
1078
+ if (!auth) return null;
1079
+ const { fetchAvailableModels } = await import("./models-I1UAEk9x.mjs").then((n) => n.r);
1080
+ const boundedFetch = (input, init) => {
1081
+ const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1082
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1083
+ return fetch(input, {
1084
+ ...init,
1085
+ signal
1086
+ });
1087
+ };
1088
+ const quotas = ingestFamilyQuotas(await fetchAvailableModels(auth.access, account.projectId, boundedFetch));
1089
+ return {
1090
+ key,
1091
+ quotas,
1092
+ updatedAt: Date.now()
1093
+ };
1094
+ } catch {
1095
+ return null;
1096
+ }
1097
+ })();
1098
+ this.quotaRefreshInFlight.set(key, refresh);
1099
+ try {
1100
+ return await refresh;
1101
+ } finally {
1102
+ this.quotaRefreshInFlight.delete(key);
1103
+ }
1104
+ }))).filter((r) => Boolean(r && Object.keys(r.quotas).length > 0));
1105
+ if (updates.length > 0) {
1106
+ for (const update of updates) {
1107
+ const target = storage.accounts.find((candidate) => this.accountKey(candidate) === update.key);
1108
+ if (target) {
1109
+ target.cachedQuota = update.quotas;
1110
+ target.cachedQuotaUpdatedAt = update.updatedAt;
1111
+ }
1112
+ }
1113
+ try {
1114
+ await this.store.mutate((s) => {
1115
+ for (const update of updates) {
1116
+ const target = s.accounts.find((candidate) => this.accountKey(candidate) === update.key);
1117
+ if (target) {
1118
+ target.cachedQuota = update.quotas;
1119
+ target.cachedQuotaUpdatedAt = update.updatedAt;
1120
+ }
1121
+ }
1122
+ });
1123
+ } catch {}
1124
+ }
1125
+ }
1126
+ /**
1127
+ * Pick the account for one request: the affinity pin wins while it is fresh,
1128
+ * healthy, and not drained for the requested model; otherwise the pool is
1129
+ * ranked by family-scoped usage (OMP-aligned) and the best candidate wins.
1130
+ */
1131
+ async pickAccount(storage, model) {
1012
1132
  const now = Date.now();
1013
1133
  for (const account of storage.accounts) clearExpiredState(account, now);
1014
- let resolved = resolveActiveAccount(storage);
1015
- if (!resolved) return void 0;
1134
+ const family = modelFamilyOf(model);
1135
+ const familyKey = familyKeyOf(model);
1016
1136
  if (this.lastUsed && now - this.lastUsed.at < 6e5) {
1017
- const last = storage.accounts[this.lastUsed.index];
1018
- if (last && last.enabled !== false && !isCoolingDown(last, now)) resolved = {
1019
- account: last,
1020
- index: this.lastUsed.index
1021
- };
1137
+ const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === this.lastUsed.key);
1138
+ if (lastIndex !== -1) {
1139
+ const last = storage.accounts[lastIndex];
1140
+ if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyDrained(last, family, now)) return {
1141
+ account: last,
1142
+ index: lastIndex
1143
+ };
1144
+ }
1022
1145
  }
1023
- if (isCoolingDown(resolved.account, now)) {
1024
- const nextIndex = pickNextAccountIndex(storage.accounts, resolved.index, now);
1025
- if (nextIndex !== resolved.index) resolved = {
1026
- account: storage.accounts[nextIndex],
1027
- index: nextIndex
1146
+ const eligible = storage.accounts.map((account, index) => ({
1147
+ account,
1148
+ index
1149
+ })).filter(({ account }) => account.enabled !== false);
1150
+ if (eligible.length === 0) return void 0;
1151
+ const ranked = rankPoolCandidates(eligible, model, now, storage.activeIndex);
1152
+ const picked = ranked.find((candidate) => candidate.blockedUntil === null);
1153
+ if (!picked) {
1154
+ const quotaExhausted = (account) => {
1155
+ if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
1156
+ const quota = familyQuotaFor(account, family);
1157
+ if ((quota?.remainingFraction ?? 1) > 0 || !quota?.resetTime) return false;
1158
+ const resetAt = Date.parse(quota.resetTime);
1159
+ return !Number.isNaN(resetAt) && resetAt > now;
1028
1160
  };
1161
+ const retryable = ranked.filter((candidate) => !quotaExhausted(candidate.account));
1162
+ const blocked = retryable.length > 0 ? retryable : ranked;
1163
+ const blockedUntil = Math.min(...blocked.map((candidate) => candidate.blockedUntil ?? now));
1164
+ throw new AgyPoolBlockedError(retryable.length > 0 ? "retryable" : "quota-exhausted", blockedUntil);
1165
+ }
1166
+ if (picked.index !== storage.activeIndex) {
1167
+ storage.activeIndex = picked.index;
1168
+ await this.store.mutate((s) => {
1169
+ s.activeIndex = picked.index;
1170
+ });
1029
1171
  }
1030
- return resolved;
1172
+ return {
1173
+ account: picked.account,
1174
+ index: picked.index
1175
+ };
1031
1176
  }
1032
1177
  /**
1033
1178
  * Adapter hook: resolve the active session (refresh if needed), healing a
1034
1179
  * missing projectId at request time — the OAuth-time loadCodeAssist may have
1035
1180
  * transiently failed even when the Google account owns a Cloud Code project
1036
1181
  * (mirrors OmniRoute's ensureAntigravityProjectAssigned + persistence).
1182
+ * @param model - requested model id; drives family-scoped quota ranking.
1037
1183
  */
1038
- async getSession() {
1039
- const storage = await this.store.load();
1040
- const picked = await this.pickAccount(storage);
1041
- if (!picked) return void 0;
1042
- const auth = await this.accessTokenFor(picked.account);
1043
- if (!auth) return void 0;
1044
- const key = this.accountKey(picked.account);
1045
- if (!picked.account.projectId && !this.projectRetryFailed.has(key)) try {
1046
- const { loadCodeAssist } = await import("./exchange-DBRzR2M-.mjs").then((n) => n.n);
1047
- const { projectId } = await loadCodeAssist(auth.access);
1048
- if (projectId) {
1049
- await this.store.mutate((s) => {
1050
- const account = s.accounts[picked.index];
1051
- if (account) {
1052
- account.projectId = projectId;
1053
- const parts = parseRefreshParts(account.refresh);
1054
- account.refresh = formatRefreshParts({
1055
- refreshToken: parts.refreshToken,
1056
- projectId,
1057
- managedProjectId: parts.managedProjectId
1058
- });
1059
- }
1060
- });
1061
- picked.account.projectId = projectId;
1062
- } else this.projectRetryFailed.add(key);
1063
- } catch {
1064
- this.projectRetryFailed.add(key);
1184
+ async getSession(model) {
1185
+ let storage = await this.store.load();
1186
+ const maxAttempts = storage.accounts.filter((account) => account.enabled !== false).length;
1187
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1188
+ if (storage.accounts.filter((account) => account.enabled !== false).length > 1) await this.refreshQuotaCache(storage);
1189
+ const picked = await this.pickAccount(storage, model);
1190
+ if (!picked) return void 0;
1191
+ const auth = await this.accessTokenFor(picked.account);
1192
+ if (!auth) {
1193
+ this.lastUsed = null;
1194
+ storage = await this.store.load();
1195
+ continue;
1196
+ }
1197
+ const key = this.accountKey(picked.account);
1198
+ if (!picked.account.projectId && !this.projectRetryFailed.has(key)) try {
1199
+ const { loadCodeAssist } = await import("./exchange-CteHnRzO.mjs").then((n) => n.n);
1200
+ const { projectId } = await loadCodeAssist(auth.access);
1201
+ if (projectId) {
1202
+ await this.store.mutate((s) => {
1203
+ const account = s.accounts.find((candidate) => this.accountKey(candidate) === key);
1204
+ if (account) {
1205
+ account.projectId = projectId;
1206
+ const parts = parseRefreshParts(account.refresh);
1207
+ account.refresh = formatRefreshParts({
1208
+ refreshToken: parts.refreshToken,
1209
+ projectId,
1210
+ managedProjectId: parts.managedProjectId
1211
+ });
1212
+ }
1213
+ });
1214
+ picked.account.projectId = projectId;
1215
+ } else this.projectRetryFailed.add(key);
1216
+ } catch {
1217
+ this.projectRetryFailed.add(key);
1218
+ }
1219
+ this.lastUsed = {
1220
+ key,
1221
+ at: Date.now()
1222
+ };
1223
+ return {
1224
+ auth,
1225
+ account: picked.account,
1226
+ index: picked.index,
1227
+ impersonation: impersonationHeadersFor(picked.account)
1228
+ };
1065
1229
  }
1066
- this.lastUsed = {
1067
- index: picked.index,
1068
- at: Date.now()
1069
- };
1070
- return {
1071
- auth,
1072
- account: picked.account,
1073
- index: picked.index,
1074
- impersonation: impersonationHeadersFor(picked.account)
1075
- };
1076
1230
  }
1077
1231
  /** Adapter hook: apply rotation decisions and fingerprint regeneration. */
1078
1232
  async reportFailure(kind, session, info) {
1079
- const storage = await this.store.load();
1080
- const account = storage.accounts[session.index];
1081
- if (!account) return;
1082
- const key = this.accountKey(account);
1233
+ if (!session?.account) return;
1234
+ const key = this.accountKey(session.account);
1083
1235
  const consecutive = (this.failureCounts.get(key) ?? 0) + 1;
1084
1236
  this.failureCounts.set(key, consecutive);
1085
- const decision = decideRotation(kind, account, consecutive, info?.retryAfterMs, info?.rateLimitCategory);
1086
- if (decision.action === "revoke") {
1087
- this.tokenCache.delete(key);
1088
- this.failureCounts.delete(key);
1089
- await this.store.save(storage);
1090
- return;
1091
- }
1092
- if (kind === "rate-limit") {
1093
- if (!account.fingerprint) {
1094
- account.fingerprint = generateFingerprint(void 0, await resolveAntigravityVersionBounded());
1095
- account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory, account.fingerprint, "initial");
1096
- } else {
1097
- const cached = peekCachedAntigravityVersion();
1098
- if (cached) updateFingerprintVersion(account.fingerprint, cached);
1099
- if (consecutive >= 2) {
1100
- const fresh = generateFingerprint(void 0, await resolveAntigravityVersionBounded());
1101
- account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory, fresh, "regenerated");
1102
- account.fingerprint = fresh;
1237
+ let nextIndexToRotate = null;
1238
+ const fpCachedVersion = kind === "rate-limit" ? peekCachedAntigravityVersion() : null;
1239
+ const fpResolvedVersion = kind === "rate-limit" && !fpCachedVersion ? await resolveAntigravityVersionBounded() : fpCachedVersion ?? "1.18.3";
1240
+ await this.store.mutate((storage) => {
1241
+ const account = storage.accounts.find((a) => this.accountKey(a) === key);
1242
+ if (!account) return;
1243
+ const decision = decideRotation(kind, account, consecutive, info?.retryAfterMs, info?.rateLimitCategory, info?.resetTime);
1244
+ if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") recordRateLimit(account, familyKeyOf(info?.model), parseFutureResetMs$1(info?.resetTime, Date.now()) ?? Date.now() + Math.min(info?.retryAfterMs ?? 3e5, 18e5));
1245
+ if (decision.action === "revoke") {
1246
+ this.tokenCache.delete(key);
1247
+ this.failureCounts.delete(key);
1248
+ return;
1249
+ }
1250
+ if (kind === "rate-limit" && info?.rateLimitCategory !== "soft_rate_limit") {
1251
+ if (!account.fingerprint) {
1252
+ account.fingerprint = generateFingerprint(void 0, fpResolvedVersion);
1253
+ account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory, account.fingerprint, "initial");
1254
+ } else {
1255
+ if (fpCachedVersion) updateFingerprintVersion(account.fingerprint, fpCachedVersion);
1256
+ if (fingerprintMode() !== "stable" && consecutive >= 2) {
1257
+ const fresh = generateFingerprint(void 0, fpResolvedVersion);
1258
+ account.fingerprintHistory = recordFingerprintVersion(account.fingerprintHistory, fresh, "regenerated");
1259
+ account.fingerprint = fresh;
1260
+ }
1103
1261
  }
1104
1262
  }
1105
- }
1106
- if (decision.action === "rotate") {
1107
- const nextIndex = pickNextAccountIndex(storage.accounts, session.index);
1108
- if (nextIndex !== session.index) {
1109
- storage.activeIndex = nextIndex;
1110
- this.onRotate?.(session.index, nextIndex, kind);
1263
+ if (decision.action === "rotate") {
1264
+ const currentIndex = storage.accounts.findIndex((a) => this.accountKey(a) === key);
1265
+ const familyKey = familyKeyOf(info?.model);
1266
+ const nextIndex = pickNextAccountIndex(storage.accounts, currentIndex >= 0 ? currentIndex : storage.activeIndex, Date.now(), familyKey);
1267
+ if (nextIndex !== storage.activeIndex) {
1268
+ storage.activeIndex = nextIndex;
1269
+ nextIndexToRotate = nextIndex;
1270
+ }
1271
+ this.lastUsed = null;
1111
1272
  }
1112
- this.lastUsed = null;
1113
- }
1114
- await this.store.save(storage);
1273
+ });
1274
+ if (nextIndexToRotate !== null) this.onRotate?.(session.index, nextIndexToRotate, kind);
1115
1275
  }
1116
1276
  /** Adapter hook: reset the failure counter after a clean completion. */
1117
1277
  async markSuccess(session) {
@@ -1124,13 +1284,13 @@ var AgySessionManager = class {
1124
1284
  * Returns the collected text or a structured error message.
1125
1285
  */
1126
1286
  async testCall(model, prompt = "Reply with exactly: OK", maxTokens = 1024) {
1127
- const session = await this.getSession();
1128
- if (!session) return {
1129
- ok: false,
1130
- error: "No agy account configured — run `dsh-agy login` first."
1131
- };
1132
1287
  try {
1133
- const { toAgyRequestBody } = await import("./translate-BQuSQW35.mjs").then((n) => n.n);
1288
+ const session = await this.getSession(model);
1289
+ if (!session) return {
1290
+ ok: false,
1291
+ error: "No agy account configured — run `dsh-agy login` first."
1292
+ };
1293
+ const { toAgyRequestBody } = await import("./translate-C1SMdm5J.mjs").then((n) => n.n);
1134
1294
  const { fetchAgyFirstOk } = await Promise.resolve().then(() => constants_exports);
1135
1295
  const { parseAgySse } = await import("./parse-D8bV2yqa.mjs").then((n) => n.n);
1136
1296
  const body = toAgyRequestBody({
@@ -1188,29 +1348,28 @@ var AgySessionManager = class {
1188
1348
  async exportBlob(index) {
1189
1349
  const account = (await this.store.load()).accounts[index];
1190
1350
  if (!account) return { error: "account not found" };
1191
- const auth = await this.accessTokenFor(account);
1192
- if (!auth) return { error: "refresh failed (revoked?)" };
1193
- const { encodeCredentialBlob } = await import("./blob-D1e7_uT1.mjs").then((n) => n.t);
1194
- const parts = parseRefreshParts(account.refresh);
1195
- return { blob: encodeCredentialBlob("agy", {
1196
- access_token: auth.access,
1197
- refresh_token: parts.refreshToken,
1198
- expires_in: Math.max(0, Math.round((auth.expires - Date.now()) / 1e3))
1199
- }) };
1351
+ try {
1352
+ const auth = await this.accessTokenFor(account);
1353
+ if (!auth) return { error: "refresh failed (revoked?)" };
1354
+ const { encodeCredentialBlob } = await import("./blob-D1e7_uT1.mjs").then((n) => n.t);
1355
+ const parts = parseRefreshParts(account.refresh);
1356
+ return { blob: encodeCredentialBlob("agy", {
1357
+ access_token: auth.access,
1358
+ refresh_token: parts.refreshToken,
1359
+ expires_in: Math.max(0, Math.round((auth.expires - Date.now()) / 1e3))
1360
+ }) };
1361
+ } catch (error) {
1362
+ return { error: error instanceof Error ? error.message : String(error) };
1363
+ }
1200
1364
  }
1201
- /** CLI/web helper: verify an account's credentials (refresh + userinfo). */
1202
- async verifyAccount(index) {
1203
- const account = (await this.store.load()).accounts[index];
1204
- if (!account) return {
1205
- ok: false,
1206
- error: "account not found"
1207
- };
1208
- const auth = await this.accessTokenFor(account);
1209
- if (!auth) return {
1210
- ok: false,
1211
- error: "refresh failed (revoked?)"
1212
- };
1365
+ /** Probe one account: refresh + userinfo; a live credential re-enables the account. */
1366
+ async probeAccount(index, account) {
1213
1367
  try {
1368
+ const auth = await this.accessTokenFor(account);
1369
+ if (!auth) return {
1370
+ ok: false,
1371
+ error: "refresh failed (revoked?)"
1372
+ };
1214
1373
  const response = await proxiedFetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { Authorization: `Bearer ${auth.access}` } });
1215
1374
  if (!response.ok) return {
1216
1375
  ok: false,
@@ -1238,8 +1397,370 @@ var AgySessionManager = class {
1238
1397
  };
1239
1398
  }
1240
1399
  }
1400
+ /** CLI/web helper: verify an account's credentials (refresh + userinfo). */
1401
+ async verifyAccount(index) {
1402
+ const account = (await this.store.load()).accounts[index];
1403
+ if (!account) return {
1404
+ ok: false,
1405
+ error: "account not found"
1406
+ };
1407
+ return this.probeAccount(index, account);
1408
+ }
1409
+ /**
1410
+ * Batch health check over all enabled accounts (or the given indices):
1411
+ * refresh + userinfo per account, live credentials re-enable the account.
1412
+ * Reports results through onHealthReport when a listener is registered.
1413
+ */
1414
+ async checkAccounts(indices) {
1415
+ const storage = await this.store.load();
1416
+ const targets = indices !== void 0 ? indices.filter((index) => storage.accounts[index]) : storage.accounts.map((_, index) => index).filter((index) => storage.accounts[index].enabled !== false);
1417
+ const results = await Promise.all(targets.map(async (index) => {
1418
+ return {
1419
+ index,
1420
+ ...await this.probeAccount(index, storage.accounts[index])
1421
+ };
1422
+ }));
1423
+ this.onHealthReport?.(results);
1424
+ return results;
1425
+ }
1426
+ /**
1427
+ * Start a background health probe on an interval (disposable stop handle).
1428
+ * The timer is unref'd unless told otherwise so harness processes can still
1429
+ * exit; the CLI loop mode passes `unref: false`.
1430
+ */
1431
+ startHealthProbe(intervalMs, options = {}) {
1432
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return () => {};
1433
+ const timer = setInterval(() => {
1434
+ this.checkAccounts().catch(() => {});
1435
+ }, intervalMs);
1436
+ if (options.unref !== false) timer.unref?.();
1437
+ return () => clearInterval(timer);
1438
+ }
1439
+ };
1440
+ //#endregion
1441
+ //#region src/store/keyring.ts
1442
+ /**
1443
+ * Master-key management and AES-256-GCM secret codec for the account store.
1444
+ *
1445
+ * The master key lives in the DSH credentials document (`~/.dsh/.credentials.yaml`,
1446
+ * 0600) under `AGY_MASTER_KEY` so both the in-harness plugin (via
1447
+ * `ctx.credentials`) and the standalone `dsh-agy` CLI (direct file read) can
1448
+ * encrypt and decrypt the same account store.
1449
+ */
1450
+ const MASTER_KEY_REF = "AGY_MASTER_KEY";
1451
+ const ENC_PREFIX$1 = "enc:v1:";
1452
+ /** AES-256-GCM codec; ciphertext format `enc:v1:<iv-b64>:<tag-b64>:<data-b64>`. */
1453
+ function createAesGcmCodec(key) {
1454
+ if (key.length !== 32) throw new Error(`createAesGcmCodec: master key must be 32 bytes, got ${key.length}`);
1455
+ return {
1456
+ encrypt(plaintext) {
1457
+ const iv = randomBytes(12);
1458
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
1459
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
1460
+ const tag = cipher.getAuthTag();
1461
+ return `${ENC_PREFIX$1}${iv.toString("base64url")}:${tag.toString("base64url")}:${data.toString("base64url")}`;
1462
+ },
1463
+ decrypt(payload) {
1464
+ if (!payload.startsWith(ENC_PREFIX$1)) throw new Error("decrypt: payload is not in encrypted format");
1465
+ const [, , ivB64 = "", tagB64 = "", dataB64 = ""] = payload.split(":");
1466
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
1467
+ decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
1468
+ return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64url")), decipher.final()]).toString("utf8");
1469
+ }
1470
+ };
1471
+ }
1472
+ /** Derive a 32-byte key from an arbitrary master-key string (SHA-256). */
1473
+ function deriveKey(masterKey) {
1474
+ return createHash("sha256").update(masterKey, "utf8").digest();
1475
+ }
1476
+ /** Default DSH home (`~/.dsh`), honoring `$DSH_HOME`. */
1477
+ function resolveDshHome() {
1478
+ return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
1479
+ }
1480
+ function homedir() {
1481
+ return process.env.HOME ?? process.env.USERPROFILE ?? ".";
1482
+ }
1483
+ /**
1484
+ * Minimal reader for the DSH credentials document: a flat YAML mapping of
1485
+ * `KEY: "value"` / `KEY: value` lines. Values are unquoted with JSON-style
1486
+ * escaping for double-quoted scalars; anything more complex fails loudly.
1487
+ */
1488
+ function readCredentialsDocument(file) {
1489
+ if (!existsSync(file)) return /* @__PURE__ */ new Map();
1490
+ const entries = /* @__PURE__ */ new Map();
1491
+ const text = readFileSync(file, "utf8");
1492
+ for (const line of text.split(/\r?\n/)) {
1493
+ const trimmed = line.trim();
1494
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("---")) continue;
1495
+ const match = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(trimmed);
1496
+ if (!match) continue;
1497
+ const [, key = "", rawValue = ""] = match;
1498
+ let value;
1499
+ if (rawValue.startsWith("\"")) try {
1500
+ value = JSON.parse(rawValue);
1501
+ } catch {
1502
+ throw new Error(`readCredentialsDocument: invalid quoted value for "${key}" in ${file}`);
1503
+ }
1504
+ else if (rawValue.startsWith("'")) {
1505
+ if (!rawValue.endsWith("'") || rawValue.length < 2) throw new Error(`readCredentialsDocument: unterminated single-quoted value for "${key}" in ${file}`);
1506
+ value = rawValue.slice(1, -1).replace(/''/g, "'");
1507
+ } else value = rawValue;
1508
+ if (value.length === 0) continue;
1509
+ entries.set(key, value);
1510
+ }
1511
+ return entries;
1512
+ }
1513
+ /**
1514
+ * POSIX owner-only enforcement. Windows mode bits never report 0600 (and
1515
+ * chmod is a no-op there), so the check is skipped on win32; the encrypted
1516
+ * account file and credentials document remain the only defense-in-depth
1517
+ * layer on that platform.
1518
+ */
1519
+ function assertOwnerOnly(file) {
1520
+ if (process.platform === "win32") return;
1521
+ const mode = statSync(file).mode & 511;
1522
+ if (mode !== 384) throw new Error(`dsh-agy: ${file} is readable beyond its owner (mode ${mode.toString(8)}); run "chmod 600" before starting again`);
1523
+ }
1524
+ /**
1525
+ * Load the master key from the DSH credentials document. Returns undefined when
1526
+ * the document or the reference is absent.
1527
+ */
1528
+ function loadMasterKey(dshHome) {
1529
+ const file = join(dshHome, ".credentials.yaml");
1530
+ if (!existsSync(file)) return void 0;
1531
+ assertOwnerOnly(file);
1532
+ return readCredentialsDocument(file).get(MASTER_KEY_REF);
1533
+ }
1534
+ /**
1535
+ * Generate and persist a fresh master key (0600) in the DSH credentials document.
1536
+ *
1537
+ * Append-only + atomic rename: the DSH credentials service owns this file and
1538
+ * preserves comments and the formatting of untouched entries (and may hold
1539
+ * YAML constructs this minimal reader cannot parse). Rewriting the whole file
1540
+ * from our parsed view would silently drop those; appending keeps them.
1541
+ */
1542
+ function persistMasterKey(dshHome, masterKey) {
1543
+ const file = join(dshHome, ".credentials.yaml");
1544
+ mkdirSync(dirname(file), { recursive: true });
1545
+ const existingText = existsSync(file) ? readFileSync(file, "utf8") : "";
1546
+ if (existingText.length > 0 && readCredentialsDocument(file).has("AGY_MASTER_KEY")) throw new Error(`persistMasterKey: ${MASTER_KEY_REF} already exists in ${file}`);
1547
+ const entry = `${MASTER_KEY_REF}: ${JSON.stringify(masterKey)}`;
1548
+ const next = existingText.length > 0 && !existingText.endsWith("\n") ? `${existingText}\n${entry}\n` : `${existingText}${entry}\n`;
1549
+ const tmp = `${file}.tmp-masterkey`;
1550
+ writeFileSync(tmp, next, { mode: 384 });
1551
+ renameSync(tmp, file);
1552
+ }
1553
+ /**
1554
+ * Resolve (load or create) the master key for a dsh home, then build the codec.
1555
+ * Creating a key writes the credentials document; read-only setups should call
1556
+ * {@link loadMasterKey} first and surface a friendly error instead.
1557
+ */
1558
+ function resolveMasterKeyCodec(dshHome) {
1559
+ let masterKey = loadMasterKey(dshHome);
1560
+ let created = false;
1561
+ if (!masterKey) {
1562
+ masterKey = randomBytes(32).toString("hex");
1563
+ persistMasterKey(dshHome, masterKey);
1564
+ created = true;
1565
+ }
1566
+ return {
1567
+ codec: createAesGcmCodec(deriveKey(masterKey)),
1568
+ created
1569
+ };
1570
+ }
1571
+ const ENC_PREFIX = "enc:v1:";
1572
+ const properFileLock = { async withLock(file, fn) {
1573
+ const release = await lockfile.lock(file, {
1574
+ stale: 3e4,
1575
+ update: 1e4,
1576
+ retries: {
1577
+ retries: 10,
1578
+ factor: 1.5,
1579
+ minTimeout: 50,
1580
+ maxTimeout: 2e3
1581
+ }
1582
+ });
1583
+ try {
1584
+ return await fn();
1585
+ } finally {
1586
+ await release();
1587
+ }
1588
+ } };
1589
+ function isEncrypted(value) {
1590
+ return value.startsWith(ENC_PREFIX);
1591
+ }
1592
+ /** Decrypt one account's refresh field when encrypted; plaintext passes through (legacy). */
1593
+ function decryptAccount(account, codec) {
1594
+ if (isEncrypted(account.refresh)) return {
1595
+ ...account,
1596
+ refresh: codec.decrypt(account.refresh)
1597
+ };
1598
+ return account;
1599
+ }
1600
+ /** Encrypt one account's refresh field (plaintext stays plaintext if no codec change). */
1601
+ function encryptAccount(account, codec) {
1602
+ if (!isEncrypted(account.refresh)) return {
1603
+ ...account,
1604
+ refresh: codec.encrypt(account.refresh)
1605
+ };
1606
+ return account;
1607
+ }
1608
+ function ensureAccountIds(storage) {
1609
+ let mutated = false;
1610
+ const seenIds = /* @__PURE__ */ new Set();
1611
+ for (const account of storage.accounts) {
1612
+ if (!account.id || seenIds.has(account.id)) {
1613
+ account.id = randomUUID();
1614
+ mutated = true;
1615
+ }
1616
+ seenIds.add(account.id);
1617
+ }
1618
+ return {
1619
+ storage,
1620
+ mutated
1621
+ };
1622
+ }
1623
+ function decryptStorage(storage, codec) {
1624
+ return {
1625
+ ...storage,
1626
+ accounts: storage.accounts.map((a) => decryptAccount(a, codec))
1627
+ };
1628
+ }
1629
+ function encryptStorage(storage, codec) {
1630
+ return {
1631
+ ...storage,
1632
+ accounts: storage.accounts.map((a) => encryptAccount(a, codec))
1633
+ };
1634
+ }
1635
+ function migrateV1ToV2(v1) {
1636
+ return {
1637
+ version: 2,
1638
+ accounts: v1.accounts.map((acc) => ({
1639
+ email: acc.email,
1640
+ refreshToken: acc.refreshToken,
1641
+ projectId: acc.projectId,
1642
+ managedProjectId: acc.managedProjectId,
1643
+ addedAt: acc.addedAt,
1644
+ lastUsed: acc.lastUsed,
1645
+ lastSwitchReason: acc.lastSwitchReason,
1646
+ rateLimitResetTimes: acc.isRateLimited && acc.rateLimitResetTime ? { default: acc.rateLimitResetTime } : void 0
1647
+ })),
1648
+ activeIndex: v1.activeIndex
1649
+ };
1650
+ }
1651
+ function migrateV2ToV3(v2) {
1652
+ return {
1653
+ version: 3,
1654
+ accounts: v2.accounts.map((acc) => ({
1655
+ email: acc.email,
1656
+ refresh: `${acc.refreshToken}|${acc.projectId ?? ""}|${acc.managedProjectId ?? ""}`,
1657
+ projectId: acc.projectId,
1658
+ managedProjectId: acc.managedProjectId,
1659
+ addedAt: acc.addedAt,
1660
+ lastUsed: acc.lastUsed,
1661
+ enabled: true,
1662
+ lastSwitchReason: acc.lastSwitchReason,
1663
+ rateLimitResetTimes: acc.rateLimitResetTimes
1664
+ })),
1665
+ activeIndex: v2.activeIndex
1666
+ };
1667
+ }
1668
+ function migrateV3ToV4(v3) {
1669
+ return {
1670
+ version: 4,
1671
+ accounts: v3.accounts,
1672
+ activeIndex: v3.activeIndex
1673
+ };
1674
+ }
1675
+ function migrateStorage(raw) {
1676
+ switch (raw.version) {
1677
+ case 1: return migrateStorage(migrateV1ToV2(raw));
1678
+ case 2: return migrateStorage(migrateV2ToV3(raw));
1679
+ case 3: return migrateStorage(migrateV3ToV4(raw));
1680
+ case 4: return raw;
1681
+ default: throw new Error(`migrateStorage: unsupported storage version ${raw.version}`);
1682
+ }
1683
+ }
1684
+ var JsonAccountStore = class {
1685
+ file;
1686
+ codec;
1687
+ lock;
1688
+ constructor(options) {
1689
+ this.file = options.file;
1690
+ this.codec = options.codec;
1691
+ this.lock = options.lock ?? properFileLock;
1692
+ }
1693
+ ensureFile() {
1694
+ if (existsSync(this.file)) return;
1695
+ mkdirSync(dirname(this.file), { recursive: true });
1696
+ const tmp = `${this.file}.tmp-init`;
1697
+ writeFileSync(tmp, JSON.stringify({
1698
+ version: 4,
1699
+ accounts: [],
1700
+ activeIndex: 0
1701
+ }) + "\n", { mode: 384 });
1702
+ renameSync(tmp, this.file);
1703
+ }
1704
+ readAndMigrateUnlocked() {
1705
+ let text;
1706
+ try {
1707
+ text = readFileSync(this.file, "utf8");
1708
+ } catch (error) {
1709
+ if (error.code === "ENOENT") return {
1710
+ storage: {
1711
+ version: 4,
1712
+ accounts: [],
1713
+ activeIndex: 0
1714
+ },
1715
+ mutated: false
1716
+ };
1717
+ throw error;
1718
+ }
1719
+ assertOwnerOnly(this.file);
1720
+ return ensureAccountIds(decryptStorage(migrateStorage(JSON.parse(text)), this.codec));
1721
+ }
1722
+ writeUnlocked(storage) {
1723
+ const encrypted = encryptStorage(ensureAccountIds(storage).storage, this.codec);
1724
+ mkdirSync(dirname(this.file), { recursive: true });
1725
+ const tmp = `${this.file}.tmp`;
1726
+ writeFileSync(tmp, JSON.stringify(encrypted, null, 2) + "\n", { mode: 384 });
1727
+ renameSync(tmp, this.file);
1728
+ }
1729
+ /**
1730
+ * Materialize and persist generated/repaired UUIDs under the file lock:
1731
+ * re-reads the fresh on-disk state under the lock so concurrent mutations
1732
+ * are never clobbered by a stale pre-lock snapshot.
1733
+ */
1734
+ async materializeIdsWithLock() {
1735
+ this.ensureFile();
1736
+ return this.lock.withLock(this.file, async () => {
1737
+ const { storage, mutated } = this.readAndMigrateUnlocked();
1738
+ if (mutated) this.writeUnlocked(storage);
1739
+ return storage;
1740
+ });
1741
+ }
1742
+ async load() {
1743
+ const { storage, mutated } = this.readAndMigrateUnlocked();
1744
+ if (mutated) return this.materializeIdsWithLock();
1745
+ return storage;
1746
+ }
1747
+ async save(storage) {
1748
+ this.ensureFile();
1749
+ await this.lock.withLock(this.file, async () => {
1750
+ this.writeUnlocked(storage);
1751
+ });
1752
+ }
1753
+ async mutate(fn) {
1754
+ this.ensureFile();
1755
+ return this.lock.withLock(this.file, async () => {
1756
+ const { storage } = this.readAndMigrateUnlocked();
1757
+ const result = await fn(storage);
1758
+ this.writeUnlocked(storage);
1759
+ return result;
1760
+ });
1761
+ }
1241
1762
  };
1242
1763
  //#endregion
1243
- export { OAUTH_TOKEN_URL as C, getAgyBootstrapUserAgent as D, getAgyBootstrapClientMetadata as E, proxiedFetch as O, OAUTH_AUTHORIZE_URL as S, fetchAgyFirstOk as T, AGY_CLIENT_ID as _, MASTER_KEY_REF as a, AGY_ENDPOINT_FALLBACKS as b, loadMasterKey as c, resolveMasterKeyCodec as d, calculateTokenExpiry as f, generateAntigravityRequestId as g, deriveAntigravitySessionId as h, JsonAccountStore as i, persistMasterKey as l, resolveAntigravityVersionBounded as m, generateFingerprint as n, createAesGcmCodec as o, resolveAntigravityVersion as p, recordFingerprintVersion as r, deriveKey as s, AgySessionManager as t, resolveDshHome as u, AGY_CLIENT_SECRET as v, OAUTH_USERINFO_URL as w, AGY_SCOPES as x, AGY_DEFAULT_REDIRECT_URI as y };
1764
+ export { AgyPoolBlockedError as A, OAUTH_USERINFO_URL as C, resolveAgyClientCredentials as D, getAgyBootstrapUserAgent as E, proxiedFetch as O, OAUTH_TOKEN_URL as S, getAgyBootstrapClientMetadata as T, generateAntigravityRequestId as _, loadMasterKey as a, AGY_SCOPES as b, resolveMasterKeyCodec as c, generateFingerprint as d, recordFingerprintVersion as f, deriveAntigravitySessionId as g, resolveAntigravityVersionBounded as h, deriveKey as i, AgyAuthError as k, AgySessionManager as l, resolveAntigravityVersion as m, MASTER_KEY_REF as n, persistMasterKey as o, calculateTokenExpiry as p, createAesGcmCodec as r, resolveDshHome as s, JsonAccountStore as t, isAgyDisabled as u, AGY_DEFAULT_REDIRECT_URI as v, fetchAgyFirstOk as w, OAUTH_AUTHORIZE_URL as x, AGY_ENDPOINT_FALLBACKS as y };
1244
1765
 
1245
- //# sourceMappingURL=session-KoIh3xdB.mjs.map
1766
+ //# sourceMappingURL=accounts-BvX5zmPa.mjs.map