dsh-agy 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +67 -11
  2. package/cordis.patch.yml +4 -2
  3. package/lib/{accounts-DB1tT9Gy.mjs → accounts-CkaHhb5H.mjs} +1155 -646
  4. package/lib/accounts-CkaHhb5H.mjs.map +1 -0
  5. package/lib/{catalog-bfGIyoba.mjs → catalog-BgdDVJEB.mjs} +17 -2
  6. package/lib/{catalog-bfGIyoba.mjs.map → catalog-BgdDVJEB.mjs.map} +1 -1
  7. package/lib/cli/index.d.mts +10 -1
  8. package/lib/cli/index.d.mts.map +1 -1
  9. package/lib/cli/index.mjs +21 -8
  10. package/lib/cli/index.mjs.map +1 -1
  11. package/lib/client.js +2168 -14
  12. package/lib/{constants-BFKYjOmh.mjs → constants-Db4tmyfb.mjs} +79 -14
  13. package/lib/constants-Db4tmyfb.mjs.map +1 -0
  14. package/lib/{exchange-dQm2mjJ5.mjs → exchange-yZPIZumk.mjs} +27 -12
  15. package/lib/exchange-yZPIZumk.mjs.map +1 -0
  16. package/lib/identity-Ci8EGc1F.mjs +96 -0
  17. package/lib/identity-Ci8EGc1F.mjs.map +1 -0
  18. package/lib/{import-D7R0GpRr.mjs → import-BaFULQnH.mjs} +11 -7
  19. package/lib/import-BaFULQnH.mjs.map +1 -0
  20. package/lib/index.mjs +2 -2
  21. package/lib/{models-ClGTQBLY.mjs → models-BGuwl50d.mjs} +35 -22
  22. package/lib/models-BGuwl50d.mjs.map +1 -0
  23. package/lib/plugin-common-B1MG00JT.mjs +1313 -0
  24. package/lib/plugin-common-B1MG00JT.mjs.map +1 -0
  25. package/lib/{proxy-DLvX8yxv.mjs → proxy-DfaL73mL.mjs} +52 -3
  26. package/lib/proxy-DfaL73mL.mjs.map +1 -0
  27. package/lib/quota-summary-5CypVffd.mjs +127 -0
  28. package/lib/quota-summary-5CypVffd.mjs.map +1 -0
  29. package/lib/{translate-D3GFUpw4.mjs → translate-BQu8HLvB.mjs} +25 -6
  30. package/lib/translate-BQu8HLvB.mjs.map +1 -0
  31. package/lib/web/plugin.d.mts +1 -0
  32. package/lib/web/plugin.d.mts.map +1 -1
  33. package/lib/web/plugin.mjs +651 -1701
  34. package/lib/web/plugin.mjs.map +1 -1
  35. package/package.json +16 -4
  36. package/lib/accounts-DB1tT9Gy.mjs.map +0 -1
  37. package/lib/constants-BFKYjOmh.mjs.map +0 -1
  38. package/lib/exchange-dQm2mjJ5.mjs.map +0 -1
  39. package/lib/identity-DsftHTJC.mjs +0 -35
  40. package/lib/identity-DsftHTJC.mjs.map +0 -1
  41. package/lib/import-D7R0GpRr.mjs.map +0 -1
  42. package/lib/models-ClGTQBLY.mjs.map +0 -1
  43. package/lib/plugin-common-B63uavj7.mjs +0 -254
  44. package/lib/plugin-common-B63uavj7.mjs.map +0 -1
  45. package/lib/proxy-DLvX8yxv.mjs.map +0 -1
  46. package/lib/translate-D3GFUpw4.mjs.map +0 -1
@@ -1,18 +1,63 @@
1
- import { a as AGY_VERSION_FALLBACK, g as isProxyRouted, h as AgyPoolBlockedError, m as AgyAuthError, p as resolveAgyClientCredentials, s as OAUTH_TOKEN_URL, t as AGY_CLIENT_ID } from "./constants-BFKYjOmh.mjs";
2
- import { a as proxiedFetch, r as isProxyUnreachableError, t as accountFetch } from "./proxy-DLvX8yxv.mjs";
3
- import { t as deriveAntigravitySessionId } from "./identity-DsftHTJC.mjs";
1
+ import { _ as setResolvedAgyVersion, a as AGY_PLATFORM_ENUM, b as isProxyRouted, d as antigravityUserAgent, g as resolveAgyClientCredentials, l as OAUTH_TOKEN_URL, p as currentAgyVersion, t as AGY_CLIENT_ID, v as AgyAuthError, y as AgyPoolBlockedError } from "./constants-Db4tmyfb.mjs";
2
+ import { a as probeFetch, l as redactCredentials, o as proxiedFetch, r as isProxyUnreachableError, t as accountFetch } from "./proxy-DfaL73mL.mjs";
3
+ import { i as generateAntigravityRequestId, r as deriveAntigravitySessionId } from "./identity-Ci8EGc1F.mjs";
4
4
  import { i as parseRefreshParts, n as calculateTokenExpiry, r as formatRefreshParts, t as accessTokenExpired } from "./auth-DPPVFwio.mjs";
5
5
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
6
6
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join, resolve } from "node:path";
8
- import lockfile from "proper-lockfile";
9
8
  import { Document, isMap, isScalar, parseDocument } from "yaml";
9
+ import lockfile from "proper-lockfile";
10
10
  //#region src/runtime/classify.ts
11
11
  /**
12
12
  * Upstream failure classification: HTTP status + headers + body → FailureKind.
13
13
  * Conservative by design: only auth-failure may revoke an account; everything
14
14
  * else cools, rotates, or retries.
15
15
  */
16
+ /**
17
+ * Phrases the upstream uses to ask for account verification rather than
18
+ * rejecting a credential. Matched case-insensitively on the raw body.
19
+ *
20
+ * Kept as a phrase list (not a bare `includes('verify')`) so an ordinary
21
+ * permission error is not mistaken for a recoverable challenge — the cost of a
22
+ * false positive is a healthy account parked behind a timed block.
23
+ */
24
+ const VERIFICATION_PHRASES = [
25
+ "validation_required",
26
+ "verify your account",
27
+ "verification required",
28
+ "verification_required"
29
+ ];
30
+ /** Whether a 403 body asks the account owner to verify rather than meaning dead credentials. */
31
+ function isVerificationRequired(bodyText) {
32
+ if (!bodyText) return false;
33
+ const text = bodyText.toLowerCase();
34
+ return VERIFICATION_PHRASES.some((phrase) => text.includes(phrase));
35
+ }
36
+ /**
37
+ * Pull the appeal/verification link out of a Google RPC error body.
38
+ *
39
+ * Prefers the structured location — `error.details[].metadata.validation_url`,
40
+ * then `appeal_url` — and only falls back to the first bare `https://` match,
41
+ * because a loose match can pick up an unrelated link. Google escapes `&` as
42
+ * `\u0026` inside the JSON string form, so the fallback unescapes it.
43
+ */
44
+ function extractVerificationUrl(bodyText) {
45
+ if (!bodyText) return void 0;
46
+ try {
47
+ const details = JSON.parse(bodyText).error?.details;
48
+ if (Array.isArray(details)) for (const detail of details) {
49
+ const meta = detail?.metadata;
50
+ if (!meta) continue;
51
+ for (const field of ["validation_url", "appeal_url"]) {
52
+ const value = meta[field];
53
+ if (typeof value === "string" && value.length > 0) return value;
54
+ }
55
+ }
56
+ } catch {}
57
+ const unescaped = bodyText.replace(/\\u0026/gi, "&");
58
+ const match = /https:\/\/[^\s"'\\]+/.exec(unescaped);
59
+ return match ? match[0] : void 0;
60
+ }
16
61
  const QUOTA_EXHAUSTED_KEYWORDS = [
17
62
  "quota_exhausted",
18
63
  "quota exhausted",
@@ -72,6 +117,12 @@ function classifyHttpError(status, headers, bodyText) {
72
117
  message: bodyText ? bodyText.slice(0, 200) : void 0
73
118
  };
74
119
  if (status === 403) {
120
+ if (isVerificationRequired(bodyText)) return {
121
+ kind: "verification-required",
122
+ status,
123
+ verificationUrl: extractVerificationUrl(bodyText),
124
+ message: bodyText ? bodyText.slice(0, 200) : void 0
125
+ };
75
126
  const category = classifyRateLimit(bodyText, void 0);
76
127
  if (category === "quota_exhausted") return {
77
128
  kind: "rate-limit",
@@ -115,6 +166,38 @@ function classifyHttpError(status, headers, bodyText) {
115
166
  message: bodyText ? bodyText.slice(0, 200) : void 0
116
167
  };
117
168
  }
169
+ /**
170
+ * Phrases the upstream returns when a conversation's SERVER-SIDE accumulated
171
+ * input for one `sessionId` passes the 1M ceiling.
172
+ *
173
+ * This is not a request-construction error: the payload is fine, but the
174
+ * upstream session that `sessionId` names has accumulated too much across turns.
175
+ * The recovery is to derive a different `sessionId` (a "generation" bump) and
176
+ * resend — hence this predicate is checked before {@link classifyHttpError} so
177
+ * such a 400 is not collapsed into a terminal `request-error`.
178
+ *
179
+ * Matched on the measured upstream text; kept phrase-specific (rather than a
180
+ * bare `includes('tokens')`) so a genuine model-capability 400 is not mistaken
181
+ * for a bumpable session wall.
182
+ */
183
+ const SESSION_ACCUMULATION_PHRASES = [
184
+ "exceeds the maximum number of tokens",
185
+ "input token count exceeds",
186
+ "token count exceeds the maximum"
187
+ ];
188
+ /**
189
+ * Whether a failed response is the recoverable per-`sessionId` accumulation
190
+ * wall rather than a malformed request.
191
+ *
192
+ * @param status - HTTP status of the failed response.
193
+ * @param bodyText - response body, when readable.
194
+ * @returns true when a session-generation bump plus one resend can recover.
195
+ */
196
+ function isSessionAccumulationOverflow(status, bodyText) {
197
+ if (status !== 400 || !bodyText) return false;
198
+ const text = bodyText.toLowerCase();
199
+ return SESSION_ACCUMULATION_PHRASES.some((phrase) => text.includes(phrase));
200
+ }
118
201
  /** Codes that carry a socket/syscall `code` worth surfacing in a failure message. */
119
202
  const TRANSPORT_CAUSE_CODES = /* @__PURE__ */ new Set([
120
203
  "ECONNREFUSED",
@@ -136,20 +219,6 @@ const TRANSPORT_CAUSE_CODES = /* @__PURE__ */ new Set([
136
219
  "UND_ERR_ABORTED"
137
220
  ]);
138
221
  /**
139
- * Strip proxy credentials from URL-like text so `user:pass` never reaches logs
140
- * or the GUI. Splits at the LAST `@` of the authority, which is the real
141
- * userinfo delimiter (RFC 3986: a host cannot contain a raw `@`), so a password
142
- * that itself contains `@` is redacted whole rather than truncated at the first
143
- * one — the failure mode of the character-class regex this replaced.
144
- */
145
- function redactCredentials(text) {
146
- return text.replace(/([a-z][a-z0-9+.-]*:\/\/)([^\s/?#]+)/gi, (match, scheme, authority) => {
147
- const at = authority.lastIndexOf("@");
148
- if (at === -1) return match;
149
- return `${scheme}<REDACTED>@${authority.slice(at + 1)}`;
150
- });
151
- }
152
- /**
153
222
  * Placeholder messages undici/Node emit while the real reason sits deeper in
154
223
  * the cause chain; they must not shadow a usable code further down.
155
224
  */
@@ -230,261 +299,248 @@ function classifyFetchError(error, routing) {
230
299
  };
231
300
  }
232
301
  //#endregion
233
- //#region src/runtime/version.ts
302
+ //#region src/store/keyring.ts
234
303
  /**
235
- * Fresh Antigravity client version resolution: fetch from the product's own
236
- * release feeds (6h TTL, single in-flight dedupe), falling back to the pinned
237
- * pool. Keeps fingerprint User-Agent version strings current — a stale version
238
- * is the most detectable fingerprint anomaly.
304
+ * Master-key management and AES-256-GCM secret codec for the account store.
305
+ *
306
+ * The master key lives in the DSH credentials document (`~/.dsh/.credentials.yaml`,
307
+ * 0600) under `AGY_MASTER_KEY` so both the in-harness plugin (via
308
+ * `ctx.credentials`) and the standalone `dsh-agy` CLI (direct file read) can
309
+ * encrypt and decrypt the same account store.
239
310
  */
240
- const VERSION_CACHE_TTL_MS = 216e5;
241
- const VERSION_FETCH_TIMEOUT_MS = 5e3;
242
- /** Source 1: Antigravity IDE auto-update release feed (array of {version}). */
243
- const IDE_RELEASE_FEED_URL = "https://antigravity-auto-updater-974169037036.us-central1.run.app/releases";
244
- /** Source 2: agy CLI GitHub releases (object with tag_name). */
245
- const CLI_RELEASE_URL = "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/latest";
246
- function compareSemver(a, b) {
247
- const pa = a.split(".").map((n) => Number(n) || 0);
248
- const pb = b.split(".").map((n) => Number(n) || 0);
249
- for (let i = 0; i < 3; i++) {
250
- const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
251
- if (diff !== 0) return diff;
252
- }
253
- return 0;
254
- }
255
- function pickNewestVersion(...versions) {
256
- const valid = versions.filter((v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v));
257
- if (valid.length === 0) return null;
258
- return valid.reduce((best, v) => !best || compareSemver(v, best) > 0 ? v : best, null);
259
- }
260
- function parseIdeReleaseFeed(payload) {
261
- if (!Array.isArray(payload)) return null;
262
- return pickNewestVersion(...payload.map((entry) => entry?.version));
263
- }
264
- function parseCliRelease(payload) {
265
- if (!payload || typeof payload !== "object") return null;
266
- const release = payload;
267
- return pickNewestVersion(release.tag_name ?? release.name);
268
- }
269
- async function fetchJsonWithTimeout(fetchImpl, url) {
270
- const controller = new AbortController();
271
- const timeoutId = setTimeout(() => controller.abort(), VERSION_FETCH_TIMEOUT_MS);
272
- try {
273
- const response = await fetchImpl(url, {
274
- headers: {
275
- Accept: "application/json",
276
- "User-Agent": "dsh-agy/0.1"
277
- },
278
- signal: controller.signal
279
- });
280
- if (!response.ok) throw new Error(`Version source ${url} returned ${response.status}`);
281
- return response.json();
282
- } finally {
283
- clearTimeout(timeoutId);
284
- }
285
- }
286
- async function resolveProductVersion(state, fallback, sourceUrl, parse, fetchImpl) {
287
- const now = Date.now();
288
- if (state.cache && now - state.cache.fetchedAt < VERSION_CACHE_TTL_MS) return pickNewestVersion(state.cache.version, fallback) ?? fallback;
289
- if (state.inFlight) return state.inFlight;
290
- state.inFlight = (async () => {
291
- let resolved = null;
292
- try {
293
- resolved = parse(await fetchJsonWithTimeout(fetchImpl, sourceUrl));
294
- } catch {
295
- resolved = null;
311
+ const MASTER_KEY_REF = "AGY_MASTER_KEY";
312
+ const ENC_PREFIX$1 = "enc:v1:";
313
+ /** AES-256-GCM codec; ciphertext format `enc:v1:<iv-b64>:<tag-b64>:<data-b64>`. */
314
+ function createAesGcmCodec(key) {
315
+ if (key.length !== 32) throw new Error(`createAesGcmCodec: master key must be 32 bytes, got ${key.length}`);
316
+ return {
317
+ encrypt(plaintext) {
318
+ const iv = randomBytes(12);
319
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
320
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
321
+ const tag = cipher.getAuthTag();
322
+ return `${ENC_PREFIX$1}${iv.toString("base64url")}:${tag.toString("base64url")}:${data.toString("base64url")}`;
323
+ },
324
+ decrypt(payload) {
325
+ if (!payload.startsWith(ENC_PREFIX$1)) throw new Error("decrypt: payload is not in encrypted format");
326
+ const [, , ivB64 = "", tagB64 = "", dataB64 = ""] = payload.split(":");
327
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
328
+ decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
329
+ return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64url")), decipher.final()]).toString("utf8");
296
330
  }
297
- const version = pickNewestVersion(resolved, state.cache?.version, fallback) ?? fallback;
298
- if (resolved) state.cache = {
299
- version,
300
- fetchedAt: Date.now()
301
- };
302
- return version;
303
- })();
304
- try {
305
- return await state.inFlight;
306
- } finally {
307
- state.inFlight = null;
308
- }
331
+ };
309
332
  }
310
- /** Resolve the newest known Antigravity version from the IDE feed. */
311
- function resolveAntigravityIdeVersion(fetchImpl = proxiedFetch) {
312
- return resolveProductVersion(ideState, AGY_VERSION_FALLBACK, IDE_RELEASE_FEED_URL, parseIdeReleaseFeed, fetchImpl);
333
+ /** Derive a 32-byte key from an arbitrary master-key string (SHA-256). */
334
+ function deriveKey(masterKey) {
335
+ return createHash("sha256").update(masterKey, "utf8").digest();
313
336
  }
314
- /** Resolve the newest known Antigravity version from the CLI releases. */
315
- function resolveAntigravityCliVersion(fetchImpl = proxiedFetch) {
316
- return resolveProductVersion(cliState, AGY_VERSION_FALLBACK, CLI_RELEASE_URL, parseCliRelease, fetchImpl);
337
+ /** Default DSH home (`~/.dsh`), honoring `$DSH_HOME`. */
338
+ function resolveDshHome() {
339
+ return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
317
340
  }
318
- /** Best available version: newest of both sources, cached 6h. */
319
- async function resolveAntigravityVersion(fetchImpl = proxiedFetch) {
320
- const [ide, cli] = await Promise.all([resolveAntigravityIdeVersion(fetchImpl), resolveAntigravityCliVersion(fetchImpl)]);
321
- return pickNewestVersion(ide, cli) ?? "1.18.3";
341
+ function homedir() {
342
+ return process.env.HOME ?? process.env.USERPROFILE ?? ".";
322
343
  }
344
+ /** A credentials reference name must be a POSIX-style identifier. */
345
+ const REF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
346
+ /** The document's non-reference sections. */
347
+ const SECTION_KEYS = ["version", "records"];
348
+ /** Every top-level key DSH's credentials provider admits; anything else is rejected. */
349
+ const DOCUMENT_TOP_LEVEL = /* @__PURE__ */ new Set([...SECTION_KEYS, "refs"]);
323
350
  /**
324
- * Bounded resolve for fingerprint generation: never block the failure path on
325
- * cold version feeds. Resolves undefined on timeout/error so callers fall
326
- * back to the pinned version pool; the abandoned fetch keeps running and
327
- * populates the 6h cache for later calls.
351
+ * Describe one YAML parse failure without quoting the source, which holds
352
+ * secrets. The parser's own message embeds the offending line.
328
353
  */
329
- async function resolveAntigravityVersionBounded(timeoutMs = 750) {
330
- let timer;
331
- try {
332
- return await Promise.race([resolveAntigravityVersion().catch(() => void 0), new Promise((resolve) => {
333
- timer = setTimeout(() => resolve(void 0), timeoutMs);
334
- })]);
335
- } finally {
336
- clearTimeout(timer);
337
- }
338
- }
339
- /** Synchronously peek at a fresh cached version (no network). */
340
- function peekCachedAntigravityVersion() {
341
- const now = Date.now();
342
- for (const state of [ideState, cliState]) if (state.cache && now - state.cache.fetchedAt < VERSION_CACHE_TTL_MS) return state.cache.version;
343
- }
344
- const ideState = { inFlight: null };
345
- const cliState = { inFlight: null };
346
- //#endregion
347
- //#region src/oauth/refresh.ts
348
- var AgyTokenRefreshError = class extends Error {
349
- code;
350
- description;
351
- status;
352
- statusText;
353
- constructor(options) {
354
- super(options.message);
355
- this.name = "AgyTokenRefreshError";
356
- this.code = options.code;
357
- this.description = options.description;
358
- this.status = options.status;
359
- this.statusText = options.statusText;
360
- }
361
- };
362
- /** Parse Google token-endpoint error payloads, tolerating varied shapes. */
363
- function parseOAuthErrorPayload(text) {
364
- if (!text) return {};
365
- try {
366
- const payload = JSON.parse(text);
367
- if (!payload || typeof payload !== "object") return { description: text };
368
- let code;
369
- if (typeof payload.error === "string") code = payload.error;
370
- else if (payload.error && typeof payload.error === "object") {
371
- code = payload.error.status ?? payload.error.code;
372
- if (!payload.error_description && payload.error.message) return {
373
- code,
374
- description: payload.error.message
375
- };
376
- }
377
- const description = payload.error_description;
378
- if (description) return {
379
- code,
380
- description
381
- };
382
- if (payload.error && typeof payload.error === "object" && payload.error.message) return {
383
- code,
384
- description: payload.error.message
385
- };
386
- return { code };
387
- } catch {
388
- return { description: text };
389
- }
354
+ function describeYamlError(error) {
355
+ const at = error.linePos?.[0];
356
+ return `${error.code ?? "parse-error"}${at ? ` at line ${at.line}, column ${at.col}` : ""}`;
390
357
  }
391
358
  /**
392
- * Refresh the access token for an account. `revoked` means Google rejected the
393
- * refresh token (`invalid_grant`) — the account must be re-authenticated.
394
- * For legacy accounts without a stored clientId, embedded credentials are tried
395
- * first with an env-override fallback before treating invalid_grant as fatal.
359
+ * Parse the credentials document and locate its `refs` section.
360
+ *
361
+ * The document is owned by DSH's credentials provider, which serializes it with
362
+ * the `yaml` package. Values are folded across physical lines (a long
363
+ * single-quoted scalar folds at whitespace) and `records` payloads nest
364
+ * arbitrary keys, so this MUST be a real parse rather than a line scan: a
365
+ * line-based reader cannot represent folding, and it lets a `records` payload
366
+ * shadow a real top-level reference.
367
+ *
368
+ * Deliberately permissive: it validates only the shape it consumes, so the read
369
+ * path keeps working on a document written by an older build. What may be
370
+ * rewritten is enforced separately, in the writer.
396
371
  */
397
- async function refreshAccessToken(auth, options) {
398
- const parts = parseRefreshParts(auth.refresh);
399
- if (!parts.refreshToken) return {
400
- type: "failed",
401
- error: new AgyTokenRefreshError({
402
- message: "Missing refresh token",
403
- status: 400,
404
- statusText: "Bad Request"
405
- })
372
+ function parseCredentialsDocument(text, file, context) {
373
+ const document = parseDocument(text, {
374
+ prettyErrors: true,
375
+ uniqueKeys: true
376
+ });
377
+ if (document.errors.length > 0) throw new Error(`${context}: cannot parse ${file}: ` + document.errors.map(describeYamlError).join("; "));
378
+ const root = document.contents;
379
+ if (!isMap(root)) throw new Error(`${context}: ${file} must be a mapping`);
380
+ const refs = root.get("refs");
381
+ if (refs !== void 0 && refs !== null && !isMap(refs)) throw new Error(`${context}: "refs" in ${file} must be a mapping`);
382
+ return {
383
+ document,
384
+ root,
385
+ refs: isMap(refs) ? refs : void 0,
386
+ flat: !root.has("version")
406
387
  };
407
- 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))));
408
- let revokedCount = 0;
409
- let transientFailure;
410
- for (const currentClientId of candidateIds) try {
411
- const startTime = Date.now();
412
- const { clientId, clientSecret } = resolveAgyClientCredentials(currentClientId);
413
- const response = await proxiedFetch(OAUTH_TOKEN_URL, {
414
- method: "POST",
415
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
416
- body: new URLSearchParams({
417
- grant_type: "refresh_token",
418
- refresh_token: parts.refreshToken,
419
- client_id: clientId,
420
- client_secret: clientSecret
421
- })
422
- }, options?.proxyUrl ? { proxyUrl: options.proxyUrl } : void 0);
423
- if (!response.ok) {
424
- const errorText = await response.text().catch(() => void 0);
425
- const { code, description } = parseOAuthErrorPayload(errorText);
426
- const details = [code, description ?? errorText].filter(Boolean).join(": ");
427
- const baseMessage = `Agy token refresh failed (${response.status} ${response.statusText})`;
428
- const message = details ? `${baseMessage} - ${details}` : baseMessage;
429
- if (code === "invalid_grant") {
430
- revokedCount++;
431
- continue;
432
- }
433
- transientFailure = {
434
- type: "failed",
435
- error: new AgyTokenRefreshError({
436
- message,
437
- code,
438
- description: description ?? errorText,
439
- status: response.status,
440
- statusText: response.statusText
441
- })
442
- };
443
- continue;
444
- }
445
- const payload = await response.json();
446
- const refreshedParts = {
447
- refreshToken: payload.refresh_token ?? parts.refreshToken,
448
- projectId: parts.projectId,
449
- managedProjectId: parts.managedProjectId
450
- };
451
- return {
452
- type: "success",
453
- auth: {
454
- access: payload.access_token,
455
- expires: calculateTokenExpiry(startTime, payload.expires_in),
456
- refresh: formatRefreshParts(refreshedParts)
457
- },
458
- clientId
459
- };
460
- } catch (error) {
461
- const raw = error instanceof Error ? error : new Error(String(error));
462
- const code = error?.code;
463
- const errorCode = error?.errorCode;
464
- const wrapped = new AgyTokenRefreshError({
465
- message: raw.message,
466
- status: 0,
467
- statusText: "Network Error",
468
- code: code ?? errorCode,
469
- description: raw.message
470
- });
471
- wrapped.cause = error;
472
- if (code) wrapped.code = code;
473
- if (errorCode) wrapped.errorCode = errorCode;
474
- transientFailure = {
475
- type: "failed",
476
- error: wrapped
477
- };
388
+ }
389
+ /**
390
+ * Read the references from the DSH credentials document.
391
+ *
392
+ * Only `refs` holds references, and a `records` payload must never be able to
393
+ * shadow one. Two compatibility cases matter:
394
+ *
395
+ * - A pre-release "flat" document keeps its references at the top level; the
396
+ * provider still reads that layout, so we do too.
397
+ * - Version 0.2.7 of this plugin appended the master key as a *top-level* key.
398
+ * The provider rejects such a document, but the key still decrypts an
399
+ * existing account store, so it is read as a fallback. Dropping it would
400
+ * silently mint a new master key and strand those accounts.
401
+ *
402
+ * Non-string and empty values are skipped, mirroring the provider.
403
+ */
404
+ function readCredentialsDocument(file) {
405
+ const entries = /* @__PURE__ */ new Map();
406
+ if (!existsSync(file)) return entries;
407
+ const text = readFileSync(file, "utf8");
408
+ if (text.trim().length === 0) return entries;
409
+ const { root, refs, flat } = parseCredentialsDocument(text, file, "readCredentialsDocument");
410
+ const section = flat ? root : refs;
411
+ if (section) for (const item of section.items) {
412
+ if (!isScalar(item.key) || !isScalar(item.value)) continue;
413
+ const key = item.key.value;
414
+ const value = item.value.value;
415
+ if (typeof key !== "string" || !REF_NAME.test(key)) continue;
416
+ if (flat && SECTION_KEYS.includes(key)) continue;
417
+ if (typeof value !== "string" || value.length === 0) continue;
418
+ entries.set(key, value);
419
+ }
420
+ if (!flat && !entries.has("AGY_MASTER_KEY")) {
421
+ const legacy = root.get(MASTER_KEY_REF);
422
+ if (typeof legacy === "string" && legacy.length > 0) entries.set(MASTER_KEY_REF, legacy);
423
+ }
424
+ return entries;
425
+ }
426
+ /**
427
+ * POSIX owner-only enforcement. Windows mode bits never report 0600 (and
428
+ * chmod is a no-op there), so the check is skipped on win32; the encrypted
429
+ * account file and credentials document remain the only defense-in-depth
430
+ * layer on that platform.
431
+ */
432
+ function assertOwnerOnly(file) {
433
+ if (process.platform === "win32") return;
434
+ const mode = statSync(file).mode & 511;
435
+ if (mode !== 384) throw new Error(`dsh-agy: ${file} is readable beyond its owner (mode ${mode.toString(8)}); run "chmod 600" before starting again`);
436
+ }
437
+ /**
438
+ * Load the master key from the DSH credentials document. Returns undefined when
439
+ * the document or the reference is absent.
440
+ */
441
+ function loadMasterKey(dshHome) {
442
+ const file = join(dshHome, ".credentials.yaml");
443
+ if (!existsSync(file)) return void 0;
444
+ assertOwnerOnly(file);
445
+ return readCredentialsDocument(file).get(MASTER_KEY_REF);
446
+ }
447
+ /** Top-level keys DSH's credentials provider admits; anything else is rejected. */
448
+ /**
449
+ * Render the version-1 layout for a pre-release "flat" document — a non-empty
450
+ * top-level mapping of reference names to non-empty string scalars, with no
451
+ * `version` key and no document directives. The original lines are nested
452
+ * verbatim under `refs:`, so comments, blank lines, and each value's spelling
453
+ * survive byte for byte. Returns undefined for anything the recognizer
454
+ * declines, which the caller must then refuse to rewrite.
455
+ *
456
+ * Nesting whole lines is safe for block scalars: an explicit indent indicator
457
+ * (`|2`) is absolute and the implied one is detected relative to the key, so
458
+ * shifting every line by the same two spaces preserves the parsed value.
459
+ */
460
+ function renderFlatLayoutMigration(text) {
461
+ for (const line of text.split("\n")) if (/^(%|---|\.\.\.)/.test(line)) return void 0;
462
+ const document = parseDocument(text, {
463
+ prettyErrors: true,
464
+ uniqueKeys: true
465
+ });
466
+ if (document.errors.length > 0) return void 0;
467
+ const flat = document.contents;
468
+ if (!isMap(flat) || flat.items.length === 0) return void 0;
469
+ for (const pair of flat.items) {
470
+ if (!isScalar(pair.key) || typeof pair.key.value !== "string") return void 0;
471
+ if (DOCUMENT_TOP_LEVEL.has(pair.key.value) || !REF_NAME.test(pair.key.value)) return void 0;
472
+ if (!isScalar(pair.value) || typeof pair.value.value !== "string") return void 0;
473
+ if (pair.value.value.length === 0) return void 0;
474
+ }
475
+ return `version: 1\nrefs:\n${text.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n")}${text.endsWith("\n") ? "" : "\n"}`;
476
+ }
477
+ /**
478
+ * Render the credentials document with {@link MASTER_KEY_REF} added under `refs`.
479
+ *
480
+ * The key MUST be nested under `refs`: DSH's credentials provider rejects any
481
+ * other top-level key, and a document it rejects makes *every* credential in
482
+ * the file unreadable, not just ours. Editing the parsed document keeps the
483
+ * comments and formatting of every untouched entry; a document this build
484
+ * cannot prove it understands is never rewritten.
485
+ */
486
+ function renderWithMasterKey(existingText, masterKey, file) {
487
+ const fresh = () => new Document({
488
+ version: 1,
489
+ refs: { [MASTER_KEY_REF]: masterKey }
490
+ }).toString();
491
+ if (existingText.trim().length === 0) return fresh();
492
+ const { document, root, refs, flat } = parseCredentialsDocument(existingText, file, "persistMasterKey");
493
+ if (flat) {
494
+ const migrated = renderFlatLayoutMigration(existingText);
495
+ if (migrated === void 0) throw new Error(`persistMasterKey: ${file} is not a credentials document this build can prove it understands; refusing to rewrite it`);
496
+ const next = parseDocument(migrated);
497
+ next.setIn(["refs", MASTER_KEY_REF], masterKey);
498
+ return next.toString();
499
+ }
500
+ for (const key of root.items) {
501
+ if (!isScalar(key.key) || typeof key.key.value !== "string") continue;
502
+ if (!DOCUMENT_TOP_LEVEL.has(key.key.value)) throw new Error(`persistMasterKey: ${file} has unknown top-level key "${key.key.value}"; refusing to rewrite it`);
503
+ }
504
+ const version = root.get("version");
505
+ if (version !== 1) throw new Error(`persistMasterKey: ${file} declares version ${JSON.stringify(version)}; refusing to rewrite it`);
506
+ if (refs) document.setIn(["refs", MASTER_KEY_REF], masterKey);
507
+ else document.set("refs", { [MASTER_KEY_REF]: masterKey });
508
+ return document.toString();
509
+ }
510
+ /**
511
+ * Generate and persist a fresh master key (0600) in the DSH credentials document.
512
+ *
513
+ * The key is added under `refs` by editing the parsed document, then written
514
+ * with an atomic tmp+rename, so every other credential — and the comments and
515
+ * formatting DSH services rely on — survives. Never rewrite the whole file from
516
+ * a rebuilt view: that would silently drop entries this build does not model.
517
+ */
518
+ function persistMasterKey(dshHome, masterKey) {
519
+ const file = join(dshHome, ".credentials.yaml");
520
+ mkdirSync(dirname(file), { recursive: true });
521
+ const existingText = existsSync(file) ? readFileSync(file, "utf8") : "";
522
+ if (existingText.length > 0 && readCredentialsDocument(file).has("AGY_MASTER_KEY")) throw new Error(`persistMasterKey: ${MASTER_KEY_REF} already exists in ${file}`);
523
+ const next = renderWithMasterKey(existingText, masterKey, file);
524
+ const tmp = `${file}.tmp-masterkey`;
525
+ writeFileSync(tmp, next, { mode: 384 });
526
+ renameSync(tmp, file);
527
+ }
528
+ /**
529
+ * Resolve (load or create) the master key for a dsh home, then build the codec.
530
+ * Creating a key writes the credentials document; read-only setups should call
531
+ * {@link loadMasterKey} first and surface a friendly error instead.
532
+ */
533
+ function resolveMasterKeyCodec(dshHome) {
534
+ let masterKey = loadMasterKey(dshHome);
535
+ let created = false;
536
+ if (!masterKey) {
537
+ masterKey = randomBytes(32).toString("hex");
538
+ persistMasterKey(dshHome, masterKey);
539
+ created = true;
478
540
  }
479
- if (transientFailure) return transientFailure;
480
- if (revokedCount === candidateIds.length) return { type: "revoked" };
481
541
  return {
482
- type: "failed",
483
- error: new AgyTokenRefreshError({
484
- message: "Token refresh failed on all candidate client IDs",
485
- status: 400,
486
- statusText: "Bad Request"
487
- })
542
+ codec: createAesGcmCodec(deriveKey(masterKey)),
543
+ created
488
544
  };
489
545
  }
490
546
  //#endregion
@@ -512,6 +568,26 @@ function isFamilyRateLimited(account, family, now = Date.now()) {
512
568
  const resetAt = account.rateLimitResetTimes?.[family];
513
569
  return typeof resetAt === "number" && resetAt > now;
514
570
  }
571
+ /**
572
+ * Proxy for a pool-level probe that belongs to no single account (the version
573
+ * feeds): the account that would serve the next request, else the first usable
574
+ * one.
575
+ *
576
+ * These feeds are not account-scoped, but the host's IP is what a user who
577
+ * configured per-account proxies asked to hide, and a probe on the env/direct
578
+ * route leaks it at boot. `undefined` means "no account route" — no accounts, or
579
+ * no usable one — and the caller then uses the env/direct route, which is also
580
+ * where an unproxied account's traffic goes anyway.
581
+ *
582
+ * Deliberately not model-aware: the probe runs once at boot, before any model is
583
+ * requested.
584
+ */
585
+ function pickProbeProxyUrl(accounts, activeIndex, now = Date.now()) {
586
+ const usable = (account) => account !== void 0 && account.enabled !== false && !isCoolingDown(account, now);
587
+ const active = accounts[activeIndex];
588
+ if (usable(active)) return active.proxy;
589
+ return accounts.find((account) => usable(account))?.proxy;
590
+ }
515
591
  /** Record a rate-limit reset for one model key, retaining the latest reset time. */
516
592
  function recordRateLimit(account, modelKey, resetAtMs) {
517
593
  const current = account.rateLimitResetTimes?.[modelKey] ?? 0;
@@ -529,12 +605,21 @@ function clearExpiredState(account, now = Date.now()) {
529
605
  if (account.coolingDownUntil && account.coolingDownUntil <= now) {
530
606
  account.coolingDownUntil = void 0;
531
607
  account.cooldownReason = void 0;
608
+ account.cooldownSetAt = void 0;
532
609
  }
533
610
  }
534
611
  /** 24h cooldown for a fully exhausted daily quota (single-account: stop hitting the wall). */
535
612
  const FULL_QUOTA_COOLDOWN_MS = 864e5;
536
613
  /** Cap for a server-reported reset time on per-minute limits (guards against bogus far-future values). */
537
614
  const MAX_RATE_LIMIT_COOLDOWN_MS = 18e5;
615
+ /**
616
+ * How long a verification challenge parks an account before the pool tries it
617
+ * again. Deliberately short and timed rather than permanent: the credential is
618
+ * intact, the wall is upstream's, and it can clear on its own — so the account
619
+ * returns to service without the user doing anything, while still not being
620
+ * hammered in the meantime.
621
+ */
622
+ const VERIFICATION_COOLDOWN_MS = 9e5;
538
623
  /** Absolute server-reported reset in ms when it lies in the future, else undefined. */
539
624
  function parseFutureResetMs$1(resetTime, now = Date.now()) {
540
625
  if (!resetTime) return void 0;
@@ -565,6 +650,7 @@ function decideRotation(kind, account, consecutiveFailures, retryAfterMs, catego
565
650
  const cooldownMs = resetMs !== void 0 ? Math.min(resetMs - now, FULL_QUOTA_COOLDOWN_MS) : FULL_QUOTA_COOLDOWN_MS;
566
651
  account.coolingDownUntil = now + Math.max(cooldownMs, 6e4);
567
652
  account.cooldownReason = "quota-exhausted";
653
+ account.cooldownSetAt = now;
568
654
  return {
569
655
  action: "cool",
570
656
  backoffMs: Math.max(cooldownMs, 6e4)
@@ -583,9 +669,21 @@ function decideRotation(kind, account, consecutiveFailures, retryAfterMs, catego
583
669
  account.verificationRequiredReason = "auth-failure";
584
670
  account.enabled = false;
585
671
  return { action: "revoke" };
672
+ case "verification-required":
673
+ account.coolingDownUntil = now + VERIFICATION_COOLDOWN_MS;
674
+ account.cooldownReason = "validation-required";
675
+ account.cooldownSetAt = now;
676
+ account.verificationRequired = true;
677
+ account.verificationRequiredAt = now;
678
+ account.verificationRequiredReason = "validation-required";
679
+ return {
680
+ action: "cool",
681
+ backoffMs: VERIFICATION_COOLDOWN_MS
682
+ };
586
683
  case "network-error":
587
684
  account.coolingDownUntil = now + backoffMs;
588
685
  account.cooldownReason = "network-error";
686
+ account.cooldownSetAt = now;
589
687
  return {
590
688
  action: "rotate",
591
689
  backoffMs
@@ -593,6 +691,7 @@ function decideRotation(kind, account, consecutiveFailures, retryAfterMs, catego
593
691
  case "project-error":
594
692
  account.coolingDownUntil = now + backoffMs;
595
693
  account.cooldownReason = "project-error";
694
+ account.cooldownSetAt = now;
596
695
  return {
597
696
  action: "cool",
598
697
  backoffMs
@@ -633,30 +732,338 @@ function computeSoftQuotaCacheTtlMs(remainingFraction, now = Date.now()) {
633
732
  if (remainingFraction < .5) return 3e5;
634
733
  return 9e5;
635
734
  }
636
- const DAY_MS = 864e5;
637
- /** Floor for remaining-time in drain-urgency scores (mirrors AuthStorage; a stale reset must not explode the score). */
638
- const DRAIN_FLOOR_MS = 6e4;
639
- /** Map a model id to its backend quota counter family (OMP getAntigravityCounterKeyForModel). */
640
- function modelFamilyOf(modelId) {
641
- if (!modelId) return void 0;
642
- const id = modelId.toLowerCase();
643
- if (id.startsWith("claude-")) return "anthropic";
644
- if (id.startsWith("gemini-") || id.startsWith("gemma-")) return "google";
645
- if (id.startsWith("gpt-") || id.startsWith("openai/")) return "openai";
646
- }
647
- /** Quota-cache key for a request: the model's family, or the unknown bucket. */
648
- function familyKeyOf(modelId) {
649
- return modelFamilyOf(modelId) ?? "unknown";
650
- }
651
- function earliestResetTime(a, b) {
652
- if (!a) return b;
653
- if (!b) return a;
654
- const ta = Date.parse(a);
655
- const tb = Date.parse(b);
656
- if (Number.isNaN(ta)) return b;
657
- if (Number.isNaN(tb)) return a;
658
- return ta <= tb ? a : b;
659
- }
735
+ //#endregion
736
+ //#region src/runtime/version.ts
737
+ /**
738
+ * Fresh Antigravity client version resolution: fetch from the product's own
739
+ * release feeds (6h TTL, single in-flight dedupe), falling back to the pinned
740
+ * pool. Keeps fingerprint User-Agent version strings current — a stale version
741
+ * is the most detectable fingerprint anomaly.
742
+ */
743
+ const VERSION_CACHE_TTL_MS = 216e5;
744
+ const VERSION_FETCH_TIMEOUT_MS = 5e3;
745
+ /** Source 2: agy CLI GitHub releases (object with tag_name). */
746
+ const CLI_RELEASE_URL = "https://api.github.com/repos/google-antigravity/antigravity-cli/releases/latest";
747
+ function compareSemver(a, b) {
748
+ const pa = a.split(".").map((n) => Number(n) || 0);
749
+ const pb = b.split(".").map((n) => Number(n) || 0);
750
+ for (let i = 0; i < 3; i++) {
751
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
752
+ if (diff !== 0) return diff;
753
+ }
754
+ return 0;
755
+ }
756
+ function pickNewestVersion(...versions) {
757
+ const valid = versions.filter((v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v));
758
+ if (valid.length === 0) return null;
759
+ return valid.reduce((best, v) => !best || compareSemver(v, best) > 0 ? v : best, null);
760
+ }
761
+ /**
762
+ * Highest version published so far in this process.
763
+ *
764
+ * Kept locally so publication is monotonic: the two feeds resolve independently
765
+ * and either may finish first, so without this the slower (possibly older) source
766
+ * could lower what a User-Agent already advertises. A version never goes
767
+ * backwards here — only the cold-start floor can be below a published value.
768
+ */
769
+ let publishedVersion;
770
+ /**
771
+ * Publish a newly observed version into the shared slot every User-Agent builder
772
+ * reads ({@link setResolvedAgyVersion}), never lowering an already-published one.
773
+ */
774
+ function publishVersion(version) {
775
+ if (version === null || version === void 0) return;
776
+ const best = pickNewestVersion(version, publishedVersion);
777
+ if (best === null || best === publishedVersion) return;
778
+ publishedVersion = best;
779
+ setResolvedAgyVersion(best);
780
+ }
781
+ function parseCliRelease(payload) {
782
+ if (!payload || typeof payload !== "object") return null;
783
+ const release = payload;
784
+ return pickNewestVersion(release.tag_name ?? release.name);
785
+ }
786
+ async function fetchJsonWithTimeout(fetchImpl, url) {
787
+ const controller = new AbortController();
788
+ const timeoutId = setTimeout(() => controller.abort(), VERSION_FETCH_TIMEOUT_MS);
789
+ try {
790
+ const response = await fetchImpl(url, {
791
+ headers: {
792
+ Accept: "application/json",
793
+ "User-Agent": antigravityUserAgent()
794
+ },
795
+ signal: controller.signal
796
+ });
797
+ if (!response.ok) throw new Error(`Version source ${url} returned ${response.status}`);
798
+ return response.json();
799
+ } finally {
800
+ clearTimeout(timeoutId);
801
+ }
802
+ }
803
+ /**
804
+ * Resolve the newest OBSERVED version from one feed, or `undefined` when the
805
+ * feed yielded nothing.
806
+ *
807
+ * Returning `undefined` rather than the pinned fallback is what keeps the
808
+ * fallback out of {@link resolveAntigravityVersion}'s cross-source `max`. When
809
+ * this returned the fallback, a source that simply failed to parse contributed
810
+ * the fallback as though it were evidence, and once the fallback was raised to
811
+ * track the live feed it started outranking genuine — but older — feed data.
812
+ * The fallback is a last resort and is applied once, at the public boundary.
813
+ */
814
+ async function resolveObservedVersion(state, sourceUrl, parse, fetchImpl) {
815
+ const now = Date.now();
816
+ if (state.cache && now - state.cache.fetchedAt < VERSION_CACHE_TTL_MS) {
817
+ publishVersion(state.cache.version);
818
+ return state.cache.version;
819
+ }
820
+ if (state.inFlight) return state.inFlight;
821
+ state.inFlight = (async () => {
822
+ let resolved = null;
823
+ try {
824
+ resolved = parse(await fetchJsonWithTimeout(fetchImpl, sourceUrl));
825
+ } catch {
826
+ resolved = null;
827
+ }
828
+ const observed = pickNewestVersion(resolved, state.cache?.version);
829
+ if (resolved) {
830
+ state.cache = {
831
+ version: observed ?? resolved,
832
+ fetchedAt: Date.now()
833
+ };
834
+ publishVersion(resolved);
835
+ }
836
+ return observed ?? void 0;
837
+ })();
838
+ try {
839
+ return await state.inFlight;
840
+ } finally {
841
+ state.inFlight = null;
842
+ }
843
+ }
844
+ /**
845
+ * The newest version actually OBSERVED on the claimed product's feed, or
846
+ * `undefined` when it yielded nothing.
847
+ *
848
+ * Reads the **CLI feed only**. This used to take the numeric max across the IDE
849
+ * and CLI feeds, which is a category error: the three Antigravity lines are
850
+ * separate version namespaces (IDE 2.x, hub 2.15.x, CLI 1.2.x), so "newest"
851
+ * across them compares unrelated numbers and picks whichever line happens to
852
+ * count higher. Since this client claims to be the CLI
853
+ * (`docs/official-identity.json`), the CLI feed is the only one that can
854
+ * describe the version we are entitled to advertise; the IDE resolver stays
855
+ * exported for the freshness gate's cross-check, never for the wire.
856
+ *
857
+ * Distinct from {@link resolveAntigravityVersion}, which substitutes the pinned
858
+ * fallback at the boundary. A caller that must tell "observed" from "assumed"
859
+ * cannot use that one — and the freshness gate is exactly such a caller, since
860
+ * the fallback is the value under test.
861
+ */
862
+ async function resolveObservedAgyVersion(fetchImpl = proxiedFetch) {
863
+ return await resolveObservedVersion(cliState, CLI_RELEASE_URL, parseCliRelease, fetchImpl) ?? void 0;
864
+ }
865
+ /** Best available version: the claimed product line, cached 6h. */
866
+ async function resolveAntigravityVersion(fetchImpl = proxiedFetch) {
867
+ return await resolveObservedAgyVersion(fetchImpl) ?? "1.2.9";
868
+ }
869
+ /**
870
+ * Bounded resolve for fingerprint generation: never block the failure path on
871
+ * cold version feeds. Resolves undefined on timeout/error so callers fall
872
+ * back to the pinned version pool; the abandoned fetch keeps running and
873
+ * populates the 6h cache for later calls.
874
+ *
875
+ * `fetchImpl` routes the probe: the feeds belong to no account, so the caller
876
+ * decides which egress they use (the CLI passes the login proxy, the plugin
877
+ * passes the pool's representative account).
878
+ */
879
+ async function resolveAntigravityVersionBounded(timeoutMs = 750, fetchImpl = proxiedFetch) {
880
+ let timer;
881
+ try {
882
+ return await Promise.race([resolveAntigravityVersion(fetchImpl).catch(() => void 0), new Promise((resolve) => {
883
+ timer = setTimeout(() => resolve(void 0), timeoutMs);
884
+ })]);
885
+ } finally {
886
+ clearTimeout(timer);
887
+ }
888
+ }
889
+ /** Synchronously peek at a fresh cached version (no network). */
890
+ function peekCachedAntigravityVersion() {
891
+ const now = Date.now();
892
+ for (const state of [cliState, ideState]) if (state.cache && now - state.cache.fetchedAt < VERSION_CACHE_TTL_MS) {
893
+ publishVersion(state.cache.version);
894
+ return state.cache.version;
895
+ }
896
+ }
897
+ const ideState = { inFlight: null };
898
+ const cliState = { inFlight: null };
899
+ //#endregion
900
+ //#region src/oauth/refresh.ts
901
+ var AgyTokenRefreshError = class extends Error {
902
+ code;
903
+ description;
904
+ status;
905
+ statusText;
906
+ constructor(options) {
907
+ super(options.message);
908
+ this.name = "AgyTokenRefreshError";
909
+ this.code = options.code;
910
+ this.description = options.description;
911
+ this.status = options.status;
912
+ this.statusText = options.statusText;
913
+ }
914
+ };
915
+ /** Parse Google token-endpoint error payloads, tolerating varied shapes. */
916
+ function parseOAuthErrorPayload(text) {
917
+ if (!text) return {};
918
+ try {
919
+ const payload = JSON.parse(text);
920
+ if (!payload || typeof payload !== "object") return { description: text };
921
+ let code;
922
+ if (typeof payload.error === "string") code = payload.error;
923
+ else if (payload.error && typeof payload.error === "object") {
924
+ code = payload.error.status ?? payload.error.code;
925
+ if (!payload.error_description && payload.error.message) return {
926
+ code,
927
+ description: payload.error.message
928
+ };
929
+ }
930
+ const description = payload.error_description;
931
+ if (description) return {
932
+ code,
933
+ description
934
+ };
935
+ if (payload.error && typeof payload.error === "object" && payload.error.message) return {
936
+ code,
937
+ description: payload.error.message
938
+ };
939
+ return { code };
940
+ } catch {
941
+ return { description: text };
942
+ }
943
+ }
944
+ /**
945
+ * Refresh the access token for an account. `revoked` means Google rejected the
946
+ * refresh token (`invalid_grant`) — the account must be re-authenticated.
947
+ * For legacy accounts without a stored clientId, embedded credentials are tried
948
+ * first with an env-override fallback before treating invalid_grant as fatal.
949
+ */
950
+ async function refreshAccessToken(auth, options) {
951
+ const parts = parseRefreshParts(auth.refresh);
952
+ if (!parts.refreshToken) return {
953
+ type: "failed",
954
+ error: new AgyTokenRefreshError({
955
+ message: "Missing refresh token",
956
+ status: 400,
957
+ statusText: "Bad Request"
958
+ })
959
+ };
960
+ 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))));
961
+ let revokedCount = 0;
962
+ let transientFailure;
963
+ for (const currentClientId of candidateIds) try {
964
+ const startTime = Date.now();
965
+ const { clientId, clientSecret } = resolveAgyClientCredentials(currentClientId);
966
+ const response = await proxiedFetch(OAUTH_TOKEN_URL, {
967
+ method: "POST",
968
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
969
+ body: new URLSearchParams({
970
+ grant_type: "refresh_token",
971
+ refresh_token: parts.refreshToken,
972
+ client_id: clientId,
973
+ client_secret: clientSecret
974
+ })
975
+ }, options?.proxyUrl ? { proxyUrl: options.proxyUrl } : void 0);
976
+ if (!response.ok) {
977
+ const errorText = await response.text().catch(() => void 0);
978
+ const { code, description } = parseOAuthErrorPayload(errorText);
979
+ const details = [code, description ?? errorText].filter(Boolean).join(": ");
980
+ const baseMessage = `Agy token refresh failed (${response.status} ${response.statusText})`;
981
+ const message = details ? `${baseMessage} - ${details}` : baseMessage;
982
+ if (code === "invalid_grant") {
983
+ revokedCount++;
984
+ continue;
985
+ }
986
+ transientFailure = {
987
+ type: "failed",
988
+ error: new AgyTokenRefreshError({
989
+ message,
990
+ code,
991
+ description: description ?? errorText,
992
+ status: response.status,
993
+ statusText: response.statusText
994
+ })
995
+ };
996
+ continue;
997
+ }
998
+ const payload = await response.json();
999
+ const refreshedParts = {
1000
+ refreshToken: payload.refresh_token ?? parts.refreshToken,
1001
+ projectId: parts.projectId,
1002
+ managedProjectId: parts.managedProjectId
1003
+ };
1004
+ return {
1005
+ type: "success",
1006
+ auth: {
1007
+ access: payload.access_token,
1008
+ expires: calculateTokenExpiry(startTime, payload.expires_in),
1009
+ refresh: formatRefreshParts(refreshedParts)
1010
+ },
1011
+ clientId
1012
+ };
1013
+ } catch (error) {
1014
+ const raw = error instanceof Error ? error : new Error(String(error));
1015
+ const code = error?.code;
1016
+ const errorCode = error?.errorCode;
1017
+ const wrapped = new AgyTokenRefreshError({
1018
+ message: raw.message,
1019
+ status: 0,
1020
+ statusText: "Network Error",
1021
+ code: code ?? errorCode,
1022
+ description: raw.message
1023
+ });
1024
+ wrapped.cause = error;
1025
+ if (code) wrapped.code = code;
1026
+ if (errorCode) wrapped.errorCode = errorCode;
1027
+ transientFailure = {
1028
+ type: "failed",
1029
+ error: wrapped
1030
+ };
1031
+ }
1032
+ if (transientFailure) return transientFailure;
1033
+ if (revokedCount === candidateIds.length) return { type: "revoked" };
1034
+ return {
1035
+ type: "failed",
1036
+ error: new AgyTokenRefreshError({
1037
+ message: "Token refresh failed on all candidate client IDs",
1038
+ status: 400,
1039
+ statusText: "Bad Request"
1040
+ })
1041
+ };
1042
+ }
1043
+ const DAY_MS = 864e5;
1044
+ /** Floor for remaining-time in drain-urgency scores (mirrors AuthStorage; a stale reset must not explode the score). */
1045
+ const DRAIN_FLOOR_MS = 6e4;
1046
+ /** Map a model id to its backend quota counter family (OMP getAntigravityCounterKeyForModel). */
1047
+ function modelFamilyOf(modelId) {
1048
+ if (!modelId) return void 0;
1049
+ const id = modelId.toLowerCase();
1050
+ if (id.startsWith("claude-")) return "anthropic";
1051
+ if (id.startsWith("gemini-") || id.startsWith("gemma-")) return "google";
1052
+ if (id.startsWith("gpt-") || id.startsWith("openai/")) return "openai";
1053
+ }
1054
+ /** Quota-cache key for a request: the model's family, or the unknown bucket. */
1055
+ function familyKeyOf(modelId) {
1056
+ return modelFamilyOf(modelId) ?? "unknown";
1057
+ }
1058
+ function earliestResetTime(a, b) {
1059
+ if (!a) return b;
1060
+ if (!b) return a;
1061
+ const ta = Date.parse(a);
1062
+ const tb = Date.parse(b);
1063
+ if (Number.isNaN(ta)) return b;
1064
+ if (Number.isNaN(tb)) return a;
1065
+ return ta <= tb ? a : b;
1066
+ }
660
1067
  /**
661
1068
  * Aggregate a fetchAvailableModels response into per-family quota records:
662
1069
  * the family's remaining fraction is its most-pressured model's, and the
@@ -695,6 +1102,29 @@ function isQuotaStale(account, now = Date.now()) {
695
1102
  const ttl = computeSoftQuotaCacheTtlMs(familyQuotaFor(account)?.remainingFraction);
696
1103
  return now - account.cachedQuotaUpdatedAt > ttl;
697
1104
  }
1105
+ /** How long a measured 5h/weekly window snapshot stays fresh. */
1106
+ const LIMITS_CACHE_TTL_MS = 6e5;
1107
+ /**
1108
+ * Whether the display-only windows need a refresh.
1109
+ *
1110
+ * A SEPARATE rule from `isQuotaStale` on purpose: that one keys off
1111
+ * `cachedQuota`/`cachedQuotaUpdatedAt`, which the scheduling path fills and a
1112
+ * SOLO account never does (the pool gate skips it). Reusing it here would report
1113
+ * "stale" on every single call for a solo account and re-probe the endpoint
1114
+ * continuously — the exact case this feature exists to serve.
1115
+ *
1116
+ * A fixed TTL is also the honest choice: the windows come from their own
1117
+ * endpoint, so there is no `remainingFraction` on hand to scale the interval by
1118
+ * without reading the very data being validated.
1119
+ *
1120
+ * @param account - the account to test.
1121
+ * @param now - current time (Unix ms).
1122
+ */
1123
+ function isLimitsStale(account, now = Date.now()) {
1124
+ const updatedAt = account.cachedLimits?.updatedAt;
1125
+ if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return true;
1126
+ return now - updatedAt > LIMITS_CACHE_TTL_MS;
1127
+ }
698
1128
  /** Whether the requested family on this account is soft-quota-exhausted (below the pre-check threshold). */
699
1129
  function isFamilyDrained(account, family, now = Date.now()) {
700
1130
  const quota = familyQuotaFor(account, family);
@@ -801,52 +1231,16 @@ function rankPoolCandidates(entries, modelId, now = Date.now(), startIndex = 0)
801
1231
  * version string once archived — that staleness is the detectable signal).
802
1232
  */
803
1233
  const DEFAULT_FINGERPRINT_DATA = {
804
- comment: "Hot-updatable fingerprint data. Version pools and client strings must track the real Antigravity product; update this file (or ship an override) when a release ships. Kept out of code so stale versions never get compiled in.",
805
- versionPool: [
806
- "1.18.3",
807
- "1.17.0",
808
- "1.16.0"
809
- ],
810
- platforms: [
811
- "windows/amd64",
812
- "darwin/arm64",
813
- "darwin/amd64"
814
- ],
815
- architectures: ["x64", "arm64"],
816
- osVersions: {
817
- "darwin": [
818
- "10.15.7",
819
- "11.6.8",
820
- "12.6.3",
821
- "13.5.2",
822
- "14.2.1",
823
- "14.5"
824
- ],
825
- "win32": [
826
- "10.0.19041",
827
- "10.0.19042",
828
- "10.0.19043",
829
- "10.0.22000",
830
- "10.0.22621",
831
- "10.0.22631"
832
- ],
833
- "linux": [
834
- "5.15.0",
835
- "5.19.0",
836
- "6.1.0",
837
- "6.2.0",
838
- "6.5.0",
839
- "6.6.0"
840
- ]
841
- },
1234
+ comment: "Hot-updatable fingerprint data. Version pools and client strings must track the real Antigravity product; update this file (or ship an override) when a release ships. Kept out of code so stale versions never get compiled in. The pool is only a last-resort fallback: production passes the resolved version explicitly (see runtime/version.ts), so a stale entry here can no longer reach the wire on its own. versionPool is the CLI line (the product this client claims, docs/official-identity.json) — NOT the IDE line, whose 2.x numbers name a different product.",
1235
+ versionPool: ["1.2.9", "1.2.5"],
1236
+ platforms: ["darwin/arm64"],
842
1237
  sdkClients: [
843
1238
  "google-cloud-sdk vscode_cloudshelleditor/0.1",
844
1239
  "google-cloud-sdk vscode/1.86.0",
845
1240
  "google-cloud-sdk vscode/1.87.0",
846
1241
  "google-cloud-sdk vscode/1.96.0"
847
1242
  ],
848
- ideTypes: ["ANTIGRAVITY"],
849
- pluginTypes: ["GEMINI"]
1243
+ ideTypes: ["ANTIGRAVITY"]
850
1244
  };
851
1245
  const USER_OVERRIDE_FILE = "agy-fingerprint-data.json";
852
1246
  /**
@@ -882,38 +1276,46 @@ function generateFingerprint(data = getFingerprintData(), version = randomFrom(d
882
1276
  sessionToken: randomBytes(16).toString("hex"),
883
1277
  userAgent: `antigravity/${version} ${platform}`,
884
1278
  apiClient: randomFrom(data.sdkClients),
885
- clientMetadata: { ideType: randomFrom(data.ideTypes) },
1279
+ clientMetadata: {
1280
+ ideType: randomFrom(data.ideTypes),
1281
+ ideVersion: version,
1282
+ platform: AGY_PLATFORM_ENUM
1283
+ },
886
1284
  createdAt: Date.now()
887
1285
  };
888
1286
  }
889
- /** Per-request randomized headers (platform + SDK client pools). */
890
- function getRandomizedHeaders(data = getFingerprintData(), version = randomFrom(data.versionPool)) {
891
- return {
892
- "User-Agent": `antigravity/${version} ${randomFrom(data.platforms)}`,
893
- "X-Goog-Api-Client": randomFrom(data.sdkClients),
894
- "Client-Metadata": JSON.stringify({ ideType: randomFrom(data.ideTypes) })
895
- };
896
- }
897
1287
  /**
898
1288
  * Deterministic fallback headers for the `stable` fingerprint mode: the first
899
1289
  * entry of each pool, every call — one fixed client identity instead of
900
1290
  * per-request randomization (OMP-style fixed-client posture).
901
1291
  */
902
1292
  function getStableHeaders(data = getFingerprintData(), version = data.versionPool[0] ?? "") {
903
- const platform = data.platforms[0] ?? "windows/amd64";
1293
+ const platform = data.platforms[0] ?? "darwin/arm64";
1294
+ const resolved = version || currentAgyVersion();
904
1295
  return {
905
- "User-Agent": `antigravity/${version || "1.18.3"} ${platform}`,
1296
+ "User-Agent": `antigravity/${resolved} ${platform}`,
906
1297
  "X-Goog-Api-Client": data.sdkClients[0] ?? "",
907
- "Client-Metadata": JSON.stringify({ ideType: data.ideTypes[0] ?? "ANTIGRAVITY" })
1298
+ clientMetadata: {
1299
+ ideType: data.ideTypes[0] ?? "ANTIGRAVITY",
1300
+ ideVersion: resolved,
1301
+ platform: AGY_PLATFORM_ENUM
1302
+ }
908
1303
  };
909
1304
  }
910
- /** Rewrite the version inside a fingerprint UA; reports whether it changed. */
1305
+ /**
1306
+ * Rewrite the version inside a fingerprint UA; reports whether it changed.
1307
+ *
1308
+ * Carries the metadata message's `ideVersion` along: they state the same fact,
1309
+ * and letting them drift is how one account comes to look like two clients.
1310
+ */
911
1311
  function updateFingerprintVersion(fingerprint, version) {
912
1312
  const pattern = /^(antigravity\/)([\d.]+)/;
913
1313
  const match = fingerprint.userAgent.match(pattern);
914
- if (!match || match[2] === version) return false;
915
- fingerprint.userAgent = fingerprint.userAgent.replace(pattern, `$1${version}`);
916
- return true;
1314
+ const uaChanged = match !== null && match[2] !== version;
1315
+ if (uaChanged) fingerprint.userAgent = fingerprint.userAgent.replace(pattern, `$1${version}`);
1316
+ const metaChanged = fingerprint.clientMetadata.ideVersion !== version;
1317
+ if (metaChanged) fingerprint.clientMetadata.ideVersion = version;
1318
+ return uaChanged || metaChanged;
917
1319
  }
918
1320
  /** Append a fingerprint to the account history (bounded), then use it as current. */
919
1321
  function recordFingerprintVersion(history, fingerprint, reason) {
@@ -940,37 +1342,40 @@ function isAgyDisabled() {
940
1342
  return envFlag("DSH_AGY_DISABLE");
941
1343
  }
942
1344
  /**
943
- * Fingerprint strategy: `dynamic` (default, upstream behavior: per-request
944
- * header randomization + regeneration on repeated rate-limits) or `stable`
945
- * (one identity per account, never regenerated, deterministic fallback
946
- * headers — mirrors OMP's fixed-client posture).
1345
+ * Fingerprint strategy: `dynamic` (default, identity regenerated after repeated
1346
+ * rate-limits) or `stable` (one identity per account, never regenerated —
1347
+ * mirrors OMP's fixed-client posture). Only the regeneration step differs; both
1348
+ * modes present a single fixed identity when no fingerprint exists yet.
947
1349
  */
948
1350
  function fingerprintMode() {
949
1351
  return process.env.DSH_AGY_FINGERPRINT_MODE === "stable" ? "stable" : "dynamic";
950
1352
  }
951
1353
  /**
952
1354
  * Resolve the impersonation headers for one request from the account's
953
- * persistent fingerprint (stable identity). The fallback randomizes per
954
- * request in `dynamic` mode and pins one identity in `stable` mode.
1355
+ * persistent fingerprint (stable identity).
1356
+ *
1357
+ * The fallback below is only reachable when an account has no stored identity —
1358
+ * normal operation creates one on first use (see `getSession`). It is therefore
1359
+ * deliberately DETERMINISTIC rather than randomized: a per-request platform or
1360
+ * version would make one account appear to be several different machines, which
1361
+ * is the anomaly this identity exists to avoid. It reads
1362
+ * {@link getFingerprintData} so a `$DSH_HOME/agy-fingerprint-data.json` override
1363
+ * still applies on this path.
955
1364
  */
956
1365
  function impersonationHeadersFor(account) {
957
1366
  const fingerprint = account.fingerprint;
958
1367
  if (fingerprint) return {
959
1368
  "User-Agent": fingerprint.userAgent,
960
1369
  "X-Goog-Api-Client": fingerprint.apiClient,
961
- "Client-Metadata": JSON.stringify(fingerprint.clientMetadata)
962
- };
963
- const headers = fingerprintMode() === "stable" ? getStableHeaders(DEFAULT_FINGERPRINT_DATA) : getRandomizedHeaders(DEFAULT_FINGERPRINT_DATA);
964
- return {
965
- "User-Agent": headers["User-Agent"],
966
- "X-Goog-Api-Client": headers["X-Goog-Api-Client"],
967
- "Client-Metadata": headers["Client-Metadata"]
1370
+ clientMetadata: fingerprint.clientMetadata
968
1371
  };
1372
+ return getStableHeaders(getFingerprintData(), currentAgyVersion());
969
1373
  }
970
1374
  var AgySessionManager = class AgySessionManager {
971
1375
  store;
972
1376
  onRotate;
973
1377
  onHealthReport;
1378
+ recordUsageOption;
974
1379
  tokenCache = /* @__PURE__ */ new Map();
975
1380
  /** In-flight refresh promises keyed by account: concurrent requests share one refresh. */
976
1381
  refreshInFlight = /* @__PURE__ */ new Map();
@@ -984,17 +1389,116 @@ var AgySessionManager = class AgySessionManager {
984
1389
  /** Refresh the token this far ahead of expiry so a request never blocks on the token endpoint. */
985
1390
  static REFRESH_SKEW_MS = 12e4;
986
1391
  /**
987
- * Session affinity (time-window approximation): DSH exposes no conversation
988
- * id, so instead of pinning per session we reuse the last-used account while
989
- * it is fresh. Keeps upstream prefix caching and sessionId continuity across
990
- * the turns of one conversation (OmniRoute pins by session for the same
991
- * reason). Cleared on rotate so a failure re-picks from activeIndex.
1392
+ * Per-conversation account affinity: the account one conversation is pinned
1393
+ * to, so its turns stay together (upstream prefix cache + `sessionId`
1394
+ * continuity) instead of re-ranking per request.
1395
+ *
1396
+ * Keyed by the conversation — `GenerateOptions.sessionId`, which the DSH agent
1397
+ * loop stamps — rather than by a single "last used" slot. That slot was a
1398
+ * stand-in for a conversation id the code assumed DSH did not expose, and with
1399
+ * two concurrent conversations the second overwrote the first's pin, so both
1400
+ * drifted across the pool independently of which account they started on.
1401
+ * Entries expire after {@link SESSION_AFFINITY_WINDOW_MS}.
1402
+ */
1403
+ affinity = /* @__PURE__ */ new Map();
1404
+ /** Bound on tracked conversations; least-recently-pinned entries are evicted. */
1405
+ static MAX_AFFINITY_ENTRIES = 64;
1406
+ /** Bucket for callers with no session identity (standalone CLI, one-shots). */
1407
+ static ANONYMOUS_CONVERSATION = "\0anonymous";
1408
+ /**
1409
+ * In-flight upstream requests per account, so selection can spread concurrent
1410
+ * fan-out across the pool instead of stacking every stream on one account.
1411
+ *
1412
+ * Deliberately a PREFERENCE, not a hard gate: when every eligible account is at
1413
+ * the cap, selection proceeds with the best-ranked one rather than waiting.
1414
+ * Blocking would need a reliable release on every path (including an abandoned
1415
+ * stream) and a bounded wait to avoid deadlock, and a leaked counter would then
1416
+ * stall real requests — a worse failure than the burst it prevents. The
1417
+ * documented limitation is therefore: this spreads load across a multi-account
1418
+ * pool, and cannot throttle a single-account one.
1419
+ */
1420
+ inFlight = /* @__PURE__ */ new Map();
1421
+ /** In-flight count for one account, discarding a leaked entry past its TTL. */
1422
+ inFlightCount(accountKey, now) {
1423
+ const entry = this.inFlight.get(accountKey);
1424
+ if (entry === void 0) return 0;
1425
+ if (now - entry.at > 6e5) {
1426
+ this.inFlight.delete(accountKey);
1427
+ return 0;
1428
+ }
1429
+ return entry.count;
1430
+ }
1431
+ /** Record that one upstream request for this account has started. */
1432
+ noteRequestStarted(account) {
1433
+ const key = this.accountKey(account);
1434
+ const now = Date.now();
1435
+ this.inFlight.set(key, {
1436
+ count: this.inFlightCount(key, now) + 1,
1437
+ at: now
1438
+ });
1439
+ }
1440
+ /** Record that one upstream request for this account has settled, on any path. */
1441
+ noteRequestSettled(account) {
1442
+ const key = this.accountKey(account);
1443
+ const now = Date.now();
1444
+ const count = this.inFlightCount(key, now);
1445
+ if (count <= 1) this.inFlight.delete(key);
1446
+ else this.inFlight.set(key, {
1447
+ count: count - 1,
1448
+ at: now
1449
+ });
1450
+ }
1451
+ /** The map key for a conversation; anonymous callers share one bucket. */
1452
+ conversationKeyFor(conversationKey) {
1453
+ const trimmed = conversationKey?.trim();
1454
+ return trimmed && trimmed.length > 0 ? trimmed : AgySessionManager.ANONYMOUS_CONVERSATION;
1455
+ }
1456
+ /** Drop expired pins and keep the map bounded. */
1457
+ pruneAffinity(now) {
1458
+ for (const [conversation, pin] of this.affinity) if (now - pin.at >= 6e5) this.affinity.delete(conversation);
1459
+ while (this.affinity.size > AgySessionManager.MAX_AFFINITY_ENTRIES) {
1460
+ const oldest = this.affinity.keys().next();
1461
+ if (oldest.done) break;
1462
+ this.affinity.delete(oldest.value);
1463
+ }
1464
+ }
1465
+ /** The account key this conversation is pinned to, when the pin is still fresh. */
1466
+ affinityFor(conversationKey, now) {
1467
+ const pin = this.affinity.get(this.conversationKeyFor(conversationKey));
1468
+ if (!pin || now - pin.at >= 6e5) return null;
1469
+ return pin.key;
1470
+ }
1471
+ /** Pin one conversation to one account. Re-inserts so eviction stays LRU. */
1472
+ setAffinity(conversationKey, accountKey, now) {
1473
+ const conversation = this.conversationKeyFor(conversationKey);
1474
+ this.affinity.delete(conversation);
1475
+ this.affinity.set(conversation, {
1476
+ key: accountKey,
1477
+ at: now
1478
+ });
1479
+ this.pruneAffinity(now);
1480
+ }
1481
+ /**
1482
+ * Drop every pin pointing at one account.
1483
+ *
1484
+ * A rotation or a skip means the account just proved unusable for the
1485
+ * conversation that was pinned to it; other conversations pinned elsewhere keep
1486
+ * their pins, which the single-slot version could not express.
992
1487
  */
993
- lastUsed = null;
1488
+ clearAffinityForAccount(accountKey) {
1489
+ for (const [conversation, pin] of this.affinity) if (pin.key === accountKey) this.affinity.delete(conversation);
1490
+ }
994
1491
  constructor(options) {
995
1492
  this.store = options.store;
996
1493
  this.onRotate = options.onRotate;
997
1494
  this.onHealthReport = options.onHealthReport;
1495
+ this.recordUsageOption = options.recordUsage;
1496
+ }
1497
+ /** Emit one non-chat usage record; a ledger failure never breaks the call. */
1498
+ emitUsage(record) {
1499
+ try {
1500
+ this.recordUsageOption?.(record);
1501
+ } catch {}
998
1502
  }
999
1503
  accountKey(account) {
1000
1504
  return account.id ?? account.email ?? `idx-${account.refresh}`;
@@ -1088,6 +1592,115 @@ var AgySessionManager = class AgySessionManager {
1088
1592
  return this.refreshToken(key, account, cached);
1089
1593
  }
1090
1594
  /**
1595
+ * Refresh the display-only 5h/weekly windows for every enabled account.
1596
+ *
1597
+ * SEPARATE from `refreshQuotaCache`, and deliberately so. That method writes
1598
+ * `cachedQuota`, which feeds the scheduling path: `rankPoolCandidates` turns a
1599
+ * measured `remainingFraction <= 0` into a `blockedUntil`, so measuring a
1600
+ * pool's quota can BLOCK an account. That is safe only with a fallback, which
1601
+ * is why `getSession` gates it on `eligible.length > 1` — and that gate is also
1602
+ * why a solo account never had `cachedLimits` populated and the limits card
1603
+ * showed "not measured yet" forever.
1604
+ *
1605
+ * Dropping the gate instead would have been a real outage, not a display fix:
1606
+ * a solo account whose family is measured at 0 gets `AgyPoolBlockedError` with
1607
+ * no other account to serve the request (verified). So the windows are fetched
1608
+ * here WITHOUT writing `cachedQuota`, which makes them safe to measure for a
1609
+ * pool of any size — a solo account included.
1610
+ *
1611
+ * Cost is bounded by the same TTL the scheduling path uses, so this runs at
1612
+ * most once per window rather than per request.
1613
+ *
1614
+ * KNOWN LIMITATION (deliberate, not overlooked): a FAILED probe writes no
1615
+ * marker, so "probe failed" and "never probed" are indistinguishable and
1616
+ * `isLimitsStale` reports stale again on the next call. The practical effect is
1617
+ * that an account whose endpoint never returns groups is re-probed each time
1618
+ * the accounts page is opened, with no backoff. Acceptable today — the call is
1619
+ * display-only, TTL-bounded, and triggered by opening a tab rather than by a
1620
+ * poll — so no negative-TTL field is added yet. Revisit if the trigger becomes
1621
+ * frequent or the pool grows enough that the extra calls matter.
1622
+ *
1623
+ * @param storage - the loaded storage document, overlaid in memory on success.
1624
+ * @param options - `force` re-probes inside the TTL.
1625
+ * @returns which accounts were measured, so a caller that asked for a refresh
1626
+ * can REPORT it. Without this a forced refresh that failed was completely
1627
+ * silent: no cache write, no `updatedAt` change, nothing on screen — the
1628
+ * same "clicked it, saw a flicker" defect as an unread RPC verdict.
1629
+ */
1630
+ async refreshLimits(storage, options = {}) {
1631
+ const now = Date.now();
1632
+ const candidates = storage.accounts.filter((account) => account.enabled !== false);
1633
+ const targets = options.force === true ? candidates : candidates.filter((account) => isLimitsStale(account, now));
1634
+ if (targets.length === 0) return {
1635
+ measured: [],
1636
+ failed: [],
1637
+ skipped: candidates.length
1638
+ };
1639
+ const probes = await Promise.all(targets.map(async (account) => {
1640
+ const key = this.accountKey(account);
1641
+ try {
1642
+ const auth = await this.accessTokenFor(account);
1643
+ if (!auth) return {
1644
+ key,
1645
+ ok: false
1646
+ };
1647
+ const { fetchQuotaSummary } = await import("./quota-summary-5CypVffd.mjs");
1648
+ const routed = accountFetch({ proxyUrl: account.proxy });
1649
+ const bounded = (input, init) => {
1650
+ const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
1651
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
1652
+ return routed(input, {
1653
+ ...init,
1654
+ signal
1655
+ });
1656
+ };
1657
+ const groups = await fetchQuotaSummary(auth.access, account.projectId, bounded);
1658
+ if (groups.length === 0) return {
1659
+ key,
1660
+ ok: false
1661
+ };
1662
+ return {
1663
+ key,
1664
+ ok: true,
1665
+ groups,
1666
+ updatedAt: Date.now()
1667
+ };
1668
+ } catch {
1669
+ return {
1670
+ key,
1671
+ ok: false
1672
+ };
1673
+ }
1674
+ }));
1675
+ const updates = probes.filter((probe) => probe.ok);
1676
+ const failed = probes.filter((probe) => !probe.ok).map((probe) => probe.key);
1677
+ const result = {
1678
+ measured: updates.map((update) => update.key),
1679
+ failed,
1680
+ skipped: candidates.length - targets.length
1681
+ };
1682
+ if (updates.length === 0) return result;
1683
+ for (const update of updates) {
1684
+ const target = storage.accounts.find((candidate) => this.accountKey(candidate) === update.key);
1685
+ if (target) target.cachedLimits = {
1686
+ groups: update.groups,
1687
+ updatedAt: update.updatedAt
1688
+ };
1689
+ }
1690
+ try {
1691
+ await this.store.mutate((s) => {
1692
+ for (const update of updates) {
1693
+ const target = s.accounts.find((candidate) => this.accountKey(candidate) === update.key);
1694
+ if (target) target.cachedLimits = {
1695
+ groups: update.groups,
1696
+ updatedAt: update.updatedAt
1697
+ };
1698
+ }
1699
+ });
1700
+ } catch {}
1701
+ return result;
1702
+ }
1703
+ /**
1091
1704
  * Refresh stale per-account quota caches (family-scoped, health-based TTL).
1092
1705
  * Failures leave the account unmeasured: ranking treats it as a fallback
1093
1706
  * instead of blocking selection on a hung endpoint.
@@ -1103,7 +1716,7 @@ var AgySessionManager = class AgySessionManager {
1103
1716
  try {
1104
1717
  const auth = await this.accessTokenFor(account);
1105
1718
  if (!auth) return null;
1106
- const { fetchAvailableModels } = await import("./models-ClGTQBLY.mjs").then((n) => n.r);
1719
+ const { fetchAvailableModels } = await import("./models-BGuwl50d.mjs").then((n) => n.r);
1107
1720
  const routed = accountFetch({ proxyUrl: account.proxy });
1108
1721
  const quotas = ingestFamilyQuotas(await fetchAvailableModels(auth.access, account.projectId, (input, init) => {
1109
1722
  const timeout = AbortSignal.timeout(AgySessionManager.QUOTA_FETCH_TIMEOUT_MS);
@@ -1155,13 +1768,14 @@ var AgySessionManager = class AgySessionManager {
1155
1768
  * healthy, and not drained for the requested model; otherwise the pool is
1156
1769
  * ranked by family-scoped usage (OMP-aligned) and the best candidate wins.
1157
1770
  */
1158
- async pickAccount(storage, model) {
1771
+ async pickAccount(storage, model, conversationKey) {
1159
1772
  const now = Date.now();
1160
1773
  for (const account of storage.accounts) clearExpiredState(account, now);
1161
1774
  const family = modelFamilyOf(model);
1162
1775
  const familyKey = familyKeyOf(model);
1163
- if (this.lastUsed && now - this.lastUsed.at < 6e5) {
1164
- const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === this.lastUsed.key);
1776
+ const pinnedKey = this.affinityFor(conversationKey, now);
1777
+ if (pinnedKey !== null) {
1778
+ const lastIndex = storage.accounts.findIndex((a) => this.accountKey(a) === pinnedKey);
1165
1779
  if (lastIndex !== -1) {
1166
1780
  const last = storage.accounts[lastIndex];
1167
1781
  if (last.enabled !== false && !isCoolingDown(last, now) && !isFamilyRateLimited(last, familyKey, now) && !isFamilyDrained(last, family, now)) return {
@@ -1176,7 +1790,7 @@ var AgySessionManager = class AgySessionManager {
1176
1790
  })).filter(({ account }) => account.enabled !== false);
1177
1791
  if (eligible.length === 0) return void 0;
1178
1792
  const ranked = rankPoolCandidates(eligible, model, now, storage.activeIndex);
1179
- const picked = ranked.find((candidate) => candidate.blockedUntil === null);
1793
+ const picked = ranked.find((candidate) => candidate.blockedUntil === null && this.inFlightCount(this.accountKey(candidate.account), now) < 3) ?? ranked.find((candidate) => candidate.blockedUntil === null);
1180
1794
  if (!picked) {
1181
1795
  const quotaExhausted = (account) => {
1182
1796
  if (account.cooldownReason === "quota-exhausted" && (account.coolingDownUntil ?? 0) > now) return true;
@@ -1207,10 +1821,24 @@ var AgySessionManager = class AgySessionManager {
1207
1821
  * transiently failed even when the Google account owns a Cloud Code project
1208
1822
  * (mirrors OmniRoute's ensureAntigravityProjectAssigned + persistence).
1209
1823
  * @param model - requested model id; drives family-scoped quota ranking.
1824
+ * @param accountIndex - resolve this exact account instead of ranking the pool.
1825
+ * Used by the management "Test call" action, where testing a different
1826
+ * account than the one the user clicked would report a result for the wrong
1827
+ * account. Deliberately does NOT update the affinity pin: a one-shot test
1828
+ * must not steer the next real conversation onto the account it probed.
1829
+ * @param conversationKey - the conversation's identity (`GenerateOptions.sessionId`),
1830
+ * which scopes account affinity. Concurrent conversations therefore hold
1831
+ * independent pins. Omitted by callers with no conversation (CLI, probes),
1832
+ * which share one anonymous bucket.
1210
1833
  */
1211
- async getSession(model) {
1834
+ async getSession(model, accountIndex, conversationKey) {
1212
1835
  let storage = await this.store.load();
1213
- const maxAttempts = storage.accounts.filter((account) => account.enabled !== false).length;
1836
+ if (accountIndex !== void 0) {
1837
+ const account = storage.accounts[accountIndex];
1838
+ if (!account) throw new Error(`account #${accountIndex} not found`);
1839
+ if (account.enabled === false) throw new Error(`account #${accountIndex} is disabled`);
1840
+ }
1841
+ const maxAttempts = accountIndex === void 0 ? storage.accounts.filter((account) => account.enabled !== false).length : 1;
1214
1842
  let proxyUnreachableCount = 0;
1215
1843
  /**
1216
1844
  * Last transport failure seen on a *proxyless* account. Such an account is
@@ -1222,8 +1850,17 @@ var AgySessionManager = class AgySessionManager {
1222
1850
  */
1223
1851
  let lastTransportError;
1224
1852
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1225
- if (storage.accounts.filter((account) => account.enabled !== false).length > 1) await this.refreshQuotaCache(storage);
1226
- const picked = await this.pickAccount(storage, model);
1853
+ const eligible = storage.accounts.filter((account) => account.enabled !== false);
1854
+ if (accountIndex === void 0 && eligible.length > 1) await this.refreshQuotaCache(storage);
1855
+ const picked = accountIndex === void 0 ? await this.pickAccount(storage, model, conversationKey) : (() => {
1856
+ const account = storage.accounts[accountIndex];
1857
+ if (!account) throw new Error(`account #${accountIndex} not found`);
1858
+ if (account.enabled === false) throw new Error(`account #${accountIndex} is disabled`);
1859
+ return {
1860
+ account,
1861
+ index: accountIndex
1862
+ };
1863
+ })();
1227
1864
  if (!picked) return void 0;
1228
1865
  let auth;
1229
1866
  try {
@@ -1231,21 +1868,24 @@ var AgySessionManager = class AgySessionManager {
1231
1868
  } catch (error) {
1232
1869
  if (Boolean(picked.account.proxy) && isProxyUnreachableError(error)) {
1233
1870
  proxyUnreachableCount++;
1871
+ if (accountIndex !== void 0) throw error;
1234
1872
  storage = await this.skipAccount(storage, picked.account);
1235
1873
  continue;
1236
1874
  }
1237
1875
  lastTransportError = error;
1876
+ if (accountIndex !== void 0) throw error;
1238
1877
  storage = await this.skipAccount(storage, picked.account);
1239
1878
  continue;
1240
1879
  }
1241
1880
  if (!auth) {
1242
- this.lastUsed = null;
1881
+ if (accountIndex !== void 0) throw new Error(`account #${accountIndex} credential is no longer valid — run \`dsh-agy login\``);
1882
+ this.clearAffinityForAccount(this.accountKey(picked.account));
1243
1883
  storage = await this.store.load();
1244
1884
  continue;
1245
1885
  }
1246
1886
  const key = this.accountKey(picked.account);
1247
1887
  if (!picked.account.projectId && !this.projectRetryFailed.has(key)) try {
1248
- const { loadCodeAssist } = await import("./exchange-dQm2mjJ5.mjs").then((n) => n.n);
1888
+ const { loadCodeAssist } = await import("./exchange-yZPIZumk.mjs").then((n) => n.n);
1249
1889
  const { projectId } = await loadCodeAssist(auth.access, { proxyUrl: picked.account.proxy });
1250
1890
  if (projectId) {
1251
1891
  await this.store.mutate((s) => {
@@ -1265,10 +1905,20 @@ var AgySessionManager = class AgySessionManager {
1265
1905
  } catch {
1266
1906
  this.projectRetryFailed.add(key);
1267
1907
  }
1268
- this.lastUsed = {
1269
- key,
1270
- at: Date.now()
1271
- };
1908
+ if (!picked.account.fingerprint) {
1909
+ const fingerprint = generateFingerprint(void 0, currentAgyVersion());
1910
+ const history = recordFingerprintVersion(picked.account.fingerprintHistory, fingerprint, "initial");
1911
+ await this.store.mutate((s) => {
1912
+ const account = s.accounts.find((candidate) => this.accountKey(candidate) === key);
1913
+ if (account && !account.fingerprint) {
1914
+ account.fingerprint = fingerprint;
1915
+ account.fingerprintHistory = history;
1916
+ }
1917
+ });
1918
+ picked.account.fingerprint = fingerprint;
1919
+ picked.account.fingerprintHistory = history;
1920
+ }
1921
+ if (accountIndex === void 0) this.setAffinity(conversationKey, key, Date.now());
1272
1922
  return {
1273
1923
  auth,
1274
1924
  account: picked.account,
@@ -1285,7 +1935,7 @@ var AgySessionManager = class AgySessionManager {
1285
1935
  * State on the account itself is deliberately left untouched (no cooldown).
1286
1936
  */
1287
1937
  async skipAccount(storage, account) {
1288
- this.lastUsed = null;
1938
+ this.clearAffinityForAccount(this.accountKey(account));
1289
1939
  const key = this.accountKey(account);
1290
1940
  const deadIndex = storage.accounts.findIndex((candidate) => this.accountKey(candidate) === key);
1291
1941
  if (deadIndex !== -1) {
@@ -1307,11 +1957,12 @@ var AgySessionManager = class AgySessionManager {
1307
1957
  this.failureCounts.set(key, consecutive);
1308
1958
  let nextIndexToRotate = null;
1309
1959
  const fpCachedVersion = kind === "rate-limit" ? peekCachedAntigravityVersion() : null;
1310
- const fpResolvedVersion = kind === "rate-limit" && !fpCachedVersion ? await resolveAntigravityVersionBounded() : fpCachedVersion ?? "1.18.3";
1960
+ const fpResolvedVersion = kind === "rate-limit" ? fpCachedVersion ?? await resolveAntigravityVersionBounded(750, probeFetch(session.account.proxy)) ?? currentAgyVersion() : currentAgyVersion();
1311
1961
  await this.store.mutate((storage) => {
1312
1962
  const account = storage.accounts.find((a) => this.accountKey(a) === key);
1313
1963
  if (!account) return;
1314
1964
  const decision = decideRotation(kind, account, consecutive, info?.retryAfterMs, info?.rateLimitCategory, info?.resetTime);
1965
+ if (kind === "verification-required" && info?.verificationUrl) account.verificationUrl = info.verificationUrl;
1315
1966
  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));
1316
1967
  if (decision.action === "revoke") {
1317
1968
  this.tokenCache.delete(key);
@@ -1339,10 +1990,19 @@ var AgySessionManager = class AgySessionManager {
1339
1990
  storage.activeIndex = nextIndex;
1340
1991
  nextIndexToRotate = nextIndex;
1341
1992
  }
1342
- this.lastUsed = null;
1993
+ this.clearAffinityForAccount(key);
1343
1994
  }
1344
1995
  });
1345
- if (nextIndexToRotate !== null) this.onRotate?.(session.index, nextIndexToRotate, kind);
1996
+ if (nextIndexToRotate !== null) {
1997
+ this.onRotate?.(session.index, nextIndexToRotate, kind);
1998
+ this.emitUsage({
1999
+ ...session.account.email === void 0 && session.account.id === void 0 ? {} : { account: session.account.email ?? session.account.id },
2000
+ source: "chat",
2001
+ ok: false,
2002
+ rotated: true,
2003
+ poolEvent: true
2004
+ });
2005
+ }
1346
2006
  }
1347
2007
  /** Adapter hook: reset the failure counter after a clean completion. */
1348
2008
  async markSuccess(session) {
@@ -1353,17 +2013,37 @@ var AgySessionManager = class AgySessionManager {
1353
2013
  /**
1354
2014
  * Test call: one short streaming request against the live backend.
1355
2015
  * Returns the collected text or a structured error message.
2016
+ * @param model - model id to exercise.
2017
+ * @param options - probe overrides.
2018
+ * - `prompt` / `maxTokens`: the request shape (defaults to a one-token reply).
2019
+ * - `accountIndex`: test this exact account instead of letting the pool rank
2020
+ * one. The management UI exposes "Test call" per account row, so without
2021
+ * this the probe ran on whichever account affinity picked, and its result
2022
+ * was both returned and recorded against that other account.
1356
2023
  */
1357
- async testCall(model, prompt = "Reply with exactly: OK", maxTokens = 1024) {
2024
+ async testCall(model, options = {}) {
2025
+ const prompt = options.prompt ?? "Reply with exactly: OK";
2026
+ const maxTokens = options.maxTokens ?? 1024;
2027
+ const startedAt = Date.now();
2028
+ let session;
1358
2029
  try {
1359
- const session = await this.getSession(model);
1360
- if (!session) return {
2030
+ session = await this.getSession(model, options.accountIndex);
2031
+ } catch (error) {
2032
+ return {
1361
2033
  ok: false,
1362
- error: "No agy account configured — run `dsh-agy login` first."
2034
+ error: error instanceof Error ? error.message : String(error)
1363
2035
  };
1364
- const { toAgyRequestBody } = await import("./translate-D3GFUpw4.mjs").then((n) => n.n);
1365
- const { fetchAgyFirstOk } = await import("./constants-BFKYjOmh.mjs").then((n) => n.l);
2036
+ }
2037
+ if (!session) return {
2038
+ ok: false,
2039
+ error: "No agy account configured — run `dsh-agy login` first."
2040
+ };
2041
+ try {
2042
+ const account = session.account.email ?? session.account.id;
2043
+ const { toAgyRequestBody } = await import("./translate-BQu8HLvB.mjs").then((n) => n.n);
2044
+ const { fetchAgyFirstOk } = await import("./constants-Db4tmyfb.mjs").then((n) => n.f);
1366
2045
  const { parseAgySse } = await import("./parse-Bs4rPkHA.mjs").then((n) => n.n);
2046
+ const requestId = generateAntigravityRequestId();
1367
2047
  const body = toAgyRequestBody({
1368
2048
  provider: "agy",
1369
2049
  model,
@@ -1378,13 +2058,15 @@ var AgySessionManager = class AgySessionManager {
1378
2058
  maxTokens
1379
2059
  }, {
1380
2060
  projectId: session.account.projectId,
1381
- sessionId: deriveAntigravitySessionId(session.account.email) ?? void 0
2061
+ sessionId: deriveAntigravitySessionId(session.account.email) ?? void 0,
2062
+ requestId
1382
2063
  });
1383
2064
  const headers = {
1384
2065
  authorization: `Bearer ${session.auth.access}`,
1385
2066
  "content-type": "application/json",
1386
2067
  accept: "text/event-stream",
1387
- ...session.impersonation
2068
+ "User-Agent": session.impersonation["User-Agent"],
2069
+ "X-Goog-Api-Client": session.impersonation["X-Goog-Api-Client"]
1388
2070
  };
1389
2071
  const routing = {
1390
2072
  proxyUrl: session.account.proxy,
@@ -1397,23 +2079,65 @@ var AgySessionManager = class AgySessionManager {
1397
2079
  }, accountFetch(routing), routing);
1398
2080
  if (!response.ok) {
1399
2081
  const text = await response.text().catch(() => "");
2082
+ this.emitUsage({
2083
+ account,
2084
+ model,
2085
+ source: "test",
2086
+ ok: false,
2087
+ latencyMs: Date.now() - startedAt
2088
+ });
1400
2089
  return {
1401
2090
  ok: false,
1402
2091
  error: `HTTP ${response.status}: ${text.slice(0, 300)}`
1403
2092
  };
1404
2093
  }
1405
- if (!response.body) return {
1406
- ok: false,
1407
- error: "no response body"
1408
- };
2094
+ if (!response.body) {
2095
+ this.emitUsage({
2096
+ account,
2097
+ model,
2098
+ source: "test",
2099
+ ok: false,
2100
+ latencyMs: Date.now() - startedAt
2101
+ });
2102
+ return {
2103
+ ok: false,
2104
+ error: "no response body"
2105
+ };
2106
+ }
1409
2107
  const text = [];
1410
- for await (const chunk of parseAgySse(response.body)) if (chunk.type === "text-delta") text.push(chunk.text);
2108
+ let usage;
2109
+ let ttftMs;
2110
+ for await (const chunk of parseAgySse(response.body)) if (chunk.type === "usage") usage = {
2111
+ input: chunk.usage.inputTokens,
2112
+ output: chunk.usage.outputTokens,
2113
+ cacheRead: chunk.usage.cacheReadTokens ?? 0,
2114
+ cacheWrite: chunk.usage.cacheWriteTokens ?? 0
2115
+ };
2116
+ else if (chunk.type === "text-delta") {
2117
+ if (ttftMs === void 0) ttftMs = Date.now() - startedAt;
2118
+ text.push(chunk.text);
2119
+ }
2120
+ const ok = text.length > 0;
2121
+ this.emitUsage({
2122
+ account,
2123
+ model,
2124
+ source: "test",
2125
+ ok,
2126
+ ...usage === void 0 ? {} : { usage },
2127
+ ...ttftMs === void 0 ? {} : { ttftMs },
2128
+ latencyMs: Date.now() - startedAt
2129
+ });
1411
2130
  return {
1412
- ok: text.length > 0,
2131
+ ok,
1413
2132
  text: text.join(""),
1414
- error: text.length > 0 ? void 0 : "empty response"
2133
+ error: ok ? void 0 : "empty response"
1415
2134
  };
1416
2135
  } catch (error) {
2136
+ this.emitUsage({
2137
+ source: "test",
2138
+ ok: false,
2139
+ latencyMs: Date.now() - startedAt
2140
+ });
1417
2141
  return {
1418
2142
  ok: false,
1419
2143
  error: error instanceof Error ? error.message : String(error)
@@ -1440,17 +2164,35 @@ var AgySessionManager = class AgySessionManager {
1440
2164
  }
1441
2165
  /** Probe one account: refresh + userinfo; a live credential re-enables the account. */
1442
2166
  async probeAccount(index, account) {
2167
+ const startedAt = Date.now();
2168
+ const key = account.email ?? account.id;
1443
2169
  try {
1444
2170
  const auth = await this.accessTokenFor(account);
1445
- if (!auth) return {
1446
- ok: false,
1447
- error: "refresh failed (revoked?)"
1448
- };
2171
+ if (!auth) {
2172
+ this.emitUsage({
2173
+ account: key,
2174
+ source: "verify",
2175
+ ok: false,
2176
+ latencyMs: Date.now() - startedAt
2177
+ });
2178
+ return {
2179
+ ok: false,
2180
+ error: "refresh failed (revoked?)"
2181
+ };
2182
+ }
1449
2183
  const response = await proxiedFetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { Authorization: `Bearer ${auth.access}` } }, account.proxy ? { proxyUrl: account.proxy } : void 0);
1450
- if (!response.ok) return {
1451
- ok: false,
1452
- error: `userinfo ${response.status}`
1453
- };
2184
+ if (!response.ok) {
2185
+ this.emitUsage({
2186
+ account: key,
2187
+ source: "verify",
2188
+ ok: false,
2189
+ latencyMs: Date.now() - startedAt
2190
+ });
2191
+ return {
2192
+ ok: false,
2193
+ error: `userinfo ${response.status}`
2194
+ };
2195
+ }
1454
2196
  const info = await response.json();
1455
2197
  await this.store.mutate((s) => {
1456
2198
  const target = s.accounts[index];
@@ -1462,11 +2204,23 @@ var AgySessionManager = class AgySessionManager {
1462
2204
  target.verificationUrl = void 0;
1463
2205
  }
1464
2206
  });
2207
+ this.emitUsage({
2208
+ account: info.email ?? key,
2209
+ source: "verify",
2210
+ ok: true,
2211
+ latencyMs: Date.now() - startedAt
2212
+ });
1465
2213
  return {
1466
2214
  ok: true,
1467
2215
  email: info.email
1468
2216
  };
1469
2217
  } catch (error) {
2218
+ this.emitUsage({
2219
+ account: key,
2220
+ source: "verify",
2221
+ ok: false,
2222
+ latencyMs: Date.now() - startedAt
2223
+ });
1470
2224
  return {
1471
2225
  ok: false,
1472
2226
  error: error instanceof Error ? error.message : String(error)
@@ -1513,251 +2267,6 @@ var AgySessionManager = class AgySessionManager {
1513
2267
  return () => clearInterval(timer);
1514
2268
  }
1515
2269
  };
1516
- //#endregion
1517
- //#region src/store/keyring.ts
1518
- /**
1519
- * Master-key management and AES-256-GCM secret codec for the account store.
1520
- *
1521
- * The master key lives in the DSH credentials document (`~/.dsh/.credentials.yaml`,
1522
- * 0600) under `AGY_MASTER_KEY` so both the in-harness plugin (via
1523
- * `ctx.credentials`) and the standalone `dsh-agy` CLI (direct file read) can
1524
- * encrypt and decrypt the same account store.
1525
- */
1526
- const MASTER_KEY_REF = "AGY_MASTER_KEY";
1527
- const ENC_PREFIX$1 = "enc:v1:";
1528
- /** AES-256-GCM codec; ciphertext format `enc:v1:<iv-b64>:<tag-b64>:<data-b64>`. */
1529
- function createAesGcmCodec(key) {
1530
- if (key.length !== 32) throw new Error(`createAesGcmCodec: master key must be 32 bytes, got ${key.length}`);
1531
- return {
1532
- encrypt(plaintext) {
1533
- const iv = randomBytes(12);
1534
- const cipher = createCipheriv("aes-256-gcm", key, iv);
1535
- const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
1536
- const tag = cipher.getAuthTag();
1537
- return `${ENC_PREFIX$1}${iv.toString("base64url")}:${tag.toString("base64url")}:${data.toString("base64url")}`;
1538
- },
1539
- decrypt(payload) {
1540
- if (!payload.startsWith(ENC_PREFIX$1)) throw new Error("decrypt: payload is not in encrypted format");
1541
- const [, , ivB64 = "", tagB64 = "", dataB64 = ""] = payload.split(":");
1542
- const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64url"));
1543
- decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
1544
- return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64url")), decipher.final()]).toString("utf8");
1545
- }
1546
- };
1547
- }
1548
- /** Derive a 32-byte key from an arbitrary master-key string (SHA-256). */
1549
- function deriveKey(masterKey) {
1550
- return createHash("sha256").update(masterKey, "utf8").digest();
1551
- }
1552
- /** Default DSH home (`~/.dsh`), honoring `$DSH_HOME`. */
1553
- function resolveDshHome() {
1554
- return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
1555
- }
1556
- function homedir() {
1557
- return process.env.HOME ?? process.env.USERPROFILE ?? ".";
1558
- }
1559
- /** A credentials reference name must be a POSIX-style identifier. */
1560
- const REF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1561
- /** The document's non-reference sections. */
1562
- const SECTION_KEYS = ["version", "records"];
1563
- /** Every top-level key DSH's credentials provider admits; anything else is rejected. */
1564
- const DOCUMENT_TOP_LEVEL = /* @__PURE__ */ new Set([...SECTION_KEYS, "refs"]);
1565
- /**
1566
- * Describe one YAML parse failure without quoting the source, which holds
1567
- * secrets. The parser's own message embeds the offending line.
1568
- */
1569
- function describeYamlError(error) {
1570
- const at = error.linePos?.[0];
1571
- return `${error.code ?? "parse-error"}${at ? ` at line ${at.line}, column ${at.col}` : ""}`;
1572
- }
1573
- /**
1574
- * Parse the credentials document and locate its `refs` section.
1575
- *
1576
- * The document is owned by DSH's credentials provider, which serializes it with
1577
- * the `yaml` package. Values are folded across physical lines (a long
1578
- * single-quoted scalar folds at whitespace) and `records` payloads nest
1579
- * arbitrary keys, so this MUST be a real parse rather than a line scan: a
1580
- * line-based reader cannot represent folding, and it lets a `records` payload
1581
- * shadow a real top-level reference.
1582
- *
1583
- * Deliberately permissive: it validates only the shape it consumes, so the read
1584
- * path keeps working on a document written by an older build. What may be
1585
- * rewritten is enforced separately, in the writer.
1586
- */
1587
- function parseCredentialsDocument(text, file, context) {
1588
- const document = parseDocument(text, {
1589
- prettyErrors: true,
1590
- uniqueKeys: true
1591
- });
1592
- if (document.errors.length > 0) throw new Error(`${context}: cannot parse ${file}: ` + document.errors.map(describeYamlError).join("; "));
1593
- const root = document.contents;
1594
- if (!isMap(root)) throw new Error(`${context}: ${file} must be a mapping`);
1595
- const refs = root.get("refs");
1596
- if (refs !== void 0 && refs !== null && !isMap(refs)) throw new Error(`${context}: "refs" in ${file} must be a mapping`);
1597
- return {
1598
- document,
1599
- root,
1600
- refs: isMap(refs) ? refs : void 0,
1601
- flat: !root.has("version")
1602
- };
1603
- }
1604
- /**
1605
- * Read the references from the DSH credentials document.
1606
- *
1607
- * Only `refs` holds references, and a `records` payload must never be able to
1608
- * shadow one. Two compatibility cases matter:
1609
- *
1610
- * - A pre-release "flat" document keeps its references at the top level; the
1611
- * provider still reads that layout, so we do too.
1612
- * - Version 0.2.7 of this plugin appended the master key as a *top-level* key.
1613
- * The provider rejects such a document, but the key still decrypts an
1614
- * existing account store, so it is read as a fallback. Dropping it would
1615
- * silently mint a new master key and strand those accounts.
1616
- *
1617
- * Non-string and empty values are skipped, mirroring the provider.
1618
- */
1619
- function readCredentialsDocument(file) {
1620
- const entries = /* @__PURE__ */ new Map();
1621
- if (!existsSync(file)) return entries;
1622
- const text = readFileSync(file, "utf8");
1623
- if (text.trim().length === 0) return entries;
1624
- const { root, refs, flat } = parseCredentialsDocument(text, file, "readCredentialsDocument");
1625
- const section = flat ? root : refs;
1626
- if (section) for (const item of section.items) {
1627
- if (!isScalar(item.key) || !isScalar(item.value)) continue;
1628
- const key = item.key.value;
1629
- const value = item.value.value;
1630
- if (typeof key !== "string" || !REF_NAME.test(key)) continue;
1631
- if (flat && SECTION_KEYS.includes(key)) continue;
1632
- if (typeof value !== "string" || value.length === 0) continue;
1633
- entries.set(key, value);
1634
- }
1635
- if (!flat && !entries.has("AGY_MASTER_KEY")) {
1636
- const legacy = root.get(MASTER_KEY_REF);
1637
- if (typeof legacy === "string" && legacy.length > 0) entries.set(MASTER_KEY_REF, legacy);
1638
- }
1639
- return entries;
1640
- }
1641
- /**
1642
- * POSIX owner-only enforcement. Windows mode bits never report 0600 (and
1643
- * chmod is a no-op there), so the check is skipped on win32; the encrypted
1644
- * account file and credentials document remain the only defense-in-depth
1645
- * layer on that platform.
1646
- */
1647
- function assertOwnerOnly(file) {
1648
- if (process.platform === "win32") return;
1649
- const mode = statSync(file).mode & 511;
1650
- if (mode !== 384) throw new Error(`dsh-agy: ${file} is readable beyond its owner (mode ${mode.toString(8)}); run "chmod 600" before starting again`);
1651
- }
1652
- /**
1653
- * Load the master key from the DSH credentials document. Returns undefined when
1654
- * the document or the reference is absent.
1655
- */
1656
- function loadMasterKey(dshHome) {
1657
- const file = join(dshHome, ".credentials.yaml");
1658
- if (!existsSync(file)) return void 0;
1659
- assertOwnerOnly(file);
1660
- return readCredentialsDocument(file).get(MASTER_KEY_REF);
1661
- }
1662
- /** Top-level keys DSH's credentials provider admits; anything else is rejected. */
1663
- /**
1664
- * Render the version-1 layout for a pre-release "flat" document — a non-empty
1665
- * top-level mapping of reference names to non-empty string scalars, with no
1666
- * `version` key and no document directives. The original lines are nested
1667
- * verbatim under `refs:`, so comments, blank lines, and each value's spelling
1668
- * survive byte for byte. Returns undefined for anything the recognizer
1669
- * declines, which the caller must then refuse to rewrite.
1670
- *
1671
- * Nesting whole lines is safe for block scalars: an explicit indent indicator
1672
- * (`|2`) is absolute and the implied one is detected relative to the key, so
1673
- * shifting every line by the same two spaces preserves the parsed value.
1674
- */
1675
- function renderFlatLayoutMigration(text) {
1676
- for (const line of text.split("\n")) if (/^(%|---|\.\.\.)/.test(line)) return void 0;
1677
- const document = parseDocument(text, {
1678
- prettyErrors: true,
1679
- uniqueKeys: true
1680
- });
1681
- if (document.errors.length > 0) return void 0;
1682
- const flat = document.contents;
1683
- if (!isMap(flat) || flat.items.length === 0) return void 0;
1684
- for (const pair of flat.items) {
1685
- if (!isScalar(pair.key) || typeof pair.key.value !== "string") return void 0;
1686
- if (DOCUMENT_TOP_LEVEL.has(pair.key.value) || !REF_NAME.test(pair.key.value)) return void 0;
1687
- if (!isScalar(pair.value) || typeof pair.value.value !== "string") return void 0;
1688
- if (pair.value.value.length === 0) return void 0;
1689
- }
1690
- return `version: 1\nrefs:\n${text.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n")}${text.endsWith("\n") ? "" : "\n"}`;
1691
- }
1692
- /**
1693
- * Render the credentials document with {@link MASTER_KEY_REF} added under `refs`.
1694
- *
1695
- * The key MUST be nested under `refs`: DSH's credentials provider rejects any
1696
- * other top-level key, and a document it rejects makes *every* credential in
1697
- * the file unreadable, not just ours. Editing the parsed document keeps the
1698
- * comments and formatting of every untouched entry; a document this build
1699
- * cannot prove it understands is never rewritten.
1700
- */
1701
- function renderWithMasterKey(existingText, masterKey, file) {
1702
- const fresh = () => new Document({
1703
- version: 1,
1704
- refs: { [MASTER_KEY_REF]: masterKey }
1705
- }).toString();
1706
- if (existingText.trim().length === 0) return fresh();
1707
- const { document, root, refs, flat } = parseCredentialsDocument(existingText, file, "persistMasterKey");
1708
- if (flat) {
1709
- const migrated = renderFlatLayoutMigration(existingText);
1710
- if (migrated === void 0) throw new Error(`persistMasterKey: ${file} is not a credentials document this build can prove it understands; refusing to rewrite it`);
1711
- const next = parseDocument(migrated);
1712
- next.setIn(["refs", MASTER_KEY_REF], masterKey);
1713
- return next.toString();
1714
- }
1715
- for (const key of root.items) {
1716
- if (!isScalar(key.key) || typeof key.key.value !== "string") continue;
1717
- if (!DOCUMENT_TOP_LEVEL.has(key.key.value)) throw new Error(`persistMasterKey: ${file} has unknown top-level key "${key.key.value}"; refusing to rewrite it`);
1718
- }
1719
- const version = root.get("version");
1720
- if (version !== 1) throw new Error(`persistMasterKey: ${file} declares version ${JSON.stringify(version)}; refusing to rewrite it`);
1721
- if (refs) document.setIn(["refs", MASTER_KEY_REF], masterKey);
1722
- else document.set("refs", { [MASTER_KEY_REF]: masterKey });
1723
- return document.toString();
1724
- }
1725
- /**
1726
- * Generate and persist a fresh master key (0600) in the DSH credentials document.
1727
- *
1728
- * The key is added under `refs` by editing the parsed document, then written
1729
- * with an atomic tmp+rename, so every other credential — and the comments and
1730
- * formatting DSH services rely on — survives. Never rewrite the whole file from
1731
- * a rebuilt view: that would silently drop entries this build does not model.
1732
- */
1733
- function persistMasterKey(dshHome, masterKey) {
1734
- const file = join(dshHome, ".credentials.yaml");
1735
- mkdirSync(dirname(file), { recursive: true });
1736
- const existingText = existsSync(file) ? readFileSync(file, "utf8") : "";
1737
- if (existingText.length > 0 && readCredentialsDocument(file).has("AGY_MASTER_KEY")) throw new Error(`persistMasterKey: ${MASTER_KEY_REF} already exists in ${file}`);
1738
- const next = renderWithMasterKey(existingText, masterKey, file);
1739
- const tmp = `${file}.tmp-masterkey`;
1740
- writeFileSync(tmp, next, { mode: 384 });
1741
- renameSync(tmp, file);
1742
- }
1743
- /**
1744
- * Resolve (load or create) the master key for a dsh home, then build the codec.
1745
- * Creating a key writes the credentials document; read-only setups should call
1746
- * {@link loadMasterKey} first and surface a friendly error instead.
1747
- */
1748
- function resolveMasterKeyCodec(dshHome) {
1749
- let masterKey = loadMasterKey(dshHome);
1750
- let created = false;
1751
- if (!masterKey) {
1752
- masterKey = randomBytes(32).toString("hex");
1753
- persistMasterKey(dshHome, masterKey);
1754
- created = true;
1755
- }
1756
- return {
1757
- codec: createAesGcmCodec(deriveKey(masterKey)),
1758
- created
1759
- };
1760
- }
1761
2270
  const ENC_PREFIX = "enc:v1:";
1762
2271
  const properFileLock = { async withLock(file, fn) {
1763
2272
  const release = await lockfile.lock(file, {
@@ -1972,6 +2481,6 @@ var JsonAccountStore = class {
1972
2481
  }
1973
2482
  };
1974
2483
  //#endregion
1975
- export { classifyHttpError as _, deriveKey as a, resolveDshHome as c, isAgyDisabled as d, generateFingerprint as f, classifyFetchError as g, resolveAntigravityVersionBounded as h, createAesGcmCodec as i, resolveMasterKeyCodec as l, resolveAntigravityVersion as m, maskProxyUrl as n, loadMasterKey as o, recordFingerprintVersion as p, MASTER_KEY_REF as r, persistMasterKey as s, JsonAccountStore as t, AgySessionManager as u, describeFetchError as v };
2484
+ export { resolveMasterKeyCodec as _, generateFingerprint as a, describeFetchError as b, resolveAntigravityVersionBounded as c, MASTER_KEY_REF as d, createAesGcmCodec as f, resolveDshHome as g, persistMasterKey as h, isAgyDisabled as i, clearExpiredState as l, loadMasterKey as m, maskProxyUrl as n, recordFingerprintVersion as o, deriveKey as p, AgySessionManager as r, resolveAntigravityVersion as s, JsonAccountStore as t, pickProbeProxyUrl as u, classifyFetchError as v, isSessionAccumulationOverflow as x, classifyHttpError as y };
1976
2485
 
1977
- //# sourceMappingURL=accounts-DB1tT9Gy.mjs.map
2486
+ //# sourceMappingURL=accounts-CkaHhb5H.mjs.map