dsh-agy 0.2.11 → 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.
- package/README.md +67 -11
- package/cordis.patch.yml +4 -2
- package/lib/{accounts-C0Oj3tQA.mjs → accounts-CkaHhb5H.mjs} +1155 -646
- package/lib/accounts-CkaHhb5H.mjs.map +1 -0
- package/lib/{catalog-bfGIyoba.mjs → catalog-BgdDVJEB.mjs} +17 -2
- package/lib/{catalog-bfGIyoba.mjs.map → catalog-BgdDVJEB.mjs.map} +1 -1
- package/lib/cli/index.d.mts +10 -1
- package/lib/cli/index.d.mts.map +1 -1
- package/lib/cli/index.mjs +21 -8
- package/lib/cli/index.mjs.map +1 -1
- package/lib/client.js +2168 -14
- package/lib/{constants-BFKYjOmh.mjs → constants-Db4tmyfb.mjs} +79 -14
- package/lib/constants-Db4tmyfb.mjs.map +1 -0
- package/lib/{exchange-dQm2mjJ5.mjs → exchange-yZPIZumk.mjs} +27 -12
- package/lib/exchange-yZPIZumk.mjs.map +1 -0
- package/lib/identity-Ci8EGc1F.mjs +96 -0
- package/lib/identity-Ci8EGc1F.mjs.map +1 -0
- package/lib/{import-D7R0GpRr.mjs → import-BaFULQnH.mjs} +11 -7
- package/lib/import-BaFULQnH.mjs.map +1 -0
- package/lib/index.mjs +2 -2
- package/lib/{models-ClGTQBLY.mjs → models-BGuwl50d.mjs} +35 -22
- package/lib/models-BGuwl50d.mjs.map +1 -0
- package/lib/plugin-common-B1MG00JT.mjs +1313 -0
- package/lib/plugin-common-B1MG00JT.mjs.map +1 -0
- package/lib/{proxy-DLvX8yxv.mjs → proxy-DfaL73mL.mjs} +52 -3
- package/lib/proxy-DfaL73mL.mjs.map +1 -0
- package/lib/quota-summary-5CypVffd.mjs +127 -0
- package/lib/quota-summary-5CypVffd.mjs.map +1 -0
- package/lib/{translate-pdeVGFpT.mjs → translate-BQu8HLvB.mjs} +57 -26
- package/lib/translate-BQu8HLvB.mjs.map +1 -0
- package/lib/web/plugin.d.mts +1 -0
- package/lib/web/plugin.d.mts.map +1 -1
- package/lib/web/plugin.mjs +651 -1701
- package/lib/web/plugin.mjs.map +1 -1
- package/package.json +17 -4
- package/lib/accounts-C0Oj3tQA.mjs.map +0 -1
- package/lib/constants-BFKYjOmh.mjs.map +0 -1
- package/lib/exchange-dQm2mjJ5.mjs.map +0 -1
- package/lib/identity-DsftHTJC.mjs +0 -35
- package/lib/identity-DsftHTJC.mjs.map +0 -1
- package/lib/import-D7R0GpRr.mjs.map +0 -1
- package/lib/models-ClGTQBLY.mjs.map +0 -1
- package/lib/plugin-common-DtF_PyxH.mjs +0 -254
- package/lib/plugin-common-DtF_PyxH.mjs.map +0 -1
- package/lib/proxy-DLvX8yxv.mjs.map +0 -1
- package/lib/translate-pdeVGFpT.mjs.map +0 -1
|
@@ -1,18 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as proxiedFetch, r as isProxyUnreachableError, t as accountFetch } from "./proxy-
|
|
3
|
-
import {
|
|
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/
|
|
302
|
+
//#region src/store/keyring.ts
|
|
234
303
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
241
|
-
const
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
311
|
-
function
|
|
312
|
-
return
|
|
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
|
-
/**
|
|
315
|
-
function
|
|
316
|
-
return
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
*
|
|
325
|
-
*
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
function
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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: {
|
|
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] ?? "
|
|
1293
|
+
const platform = data.platforms[0] ?? "darwin/arm64";
|
|
1294
|
+
const resolved = version || currentAgyVersion();
|
|
904
1295
|
return {
|
|
905
|
-
"User-Agent": `antigravity/${
|
|
1296
|
+
"User-Agent": `antigravity/${resolved} ${platform}`,
|
|
906
1297
|
"X-Goog-Api-Client": data.sdkClients[0] ?? "",
|
|
907
|
-
|
|
1298
|
+
clientMetadata: {
|
|
1299
|
+
ideType: data.ideTypes[0] ?? "ANTIGRAVITY",
|
|
1300
|
+
ideVersion: resolved,
|
|
1301
|
+
platform: AGY_PLATFORM_ENUM
|
|
1302
|
+
}
|
|
908
1303
|
};
|
|
909
1304
|
}
|
|
910
|
-
/**
|
|
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
|
-
|
|
915
|
-
fingerprint.userAgent = fingerprint.userAgent.replace(pattern, `$1${version}`);
|
|
916
|
-
|
|
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,
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
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).
|
|
954
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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.
|
|
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"
|
|
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.
|
|
1993
|
+
this.clearAffinityForAccount(key);
|
|
1343
1994
|
}
|
|
1344
1995
|
});
|
|
1345
|
-
if (nextIndexToRotate !== null)
|
|
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,
|
|
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
|
-
|
|
1360
|
-
|
|
2030
|
+
session = await this.getSession(model, options.accountIndex);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
return {
|
|
1361
2033
|
ok: false,
|
|
1362
|
-
error:
|
|
2034
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1363
2035
|
};
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
|
|
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)
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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
|
|
2131
|
+
ok,
|
|
1413
2132
|
text: text.join(""),
|
|
1414
|
-
error:
|
|
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)
|
|
1446
|
-
|
|
1447
|
-
|
|
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)
|
|
1451
|
-
|
|
1452
|
-
|
|
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 {
|
|
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-
|
|
2486
|
+
//# sourceMappingURL=accounts-CkaHhb5H.mjs.map
|