@centerforagenticai/pi-multi-account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,397 @@
1
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import {
4
+ HISTORY_SCHEMA_VERSION,
5
+ iterateHistory,
6
+ type HistoryLogRecord,
7
+ type HistoryRecordEnvelope,
8
+ type HistoryGapRecord,
9
+ } from "./history-store.js";
10
+
11
+ const MAX_ATTESTATION_BYTES = 4096;
12
+ const SUPPORTED_ATTESTATION_VERSION = 1;
13
+
14
+ // The canonical emitter build identity for history records
15
+ // Must match the buildProvenance field in WindowSample/CostRecord
16
+ const CANONICAL_BUILD_IDENTITY = "pi-multi-account@0.1.0";
17
+
18
+ export type CoverageState = "unknown" | "partial" | "complete";
19
+
20
+ export interface CoverageAttestation {
21
+ readonly attestationVersion: number;
22
+ readonly historySchemaVersion: number;
23
+ readonly buildProvenance: string;
24
+ readonly attestedAtMs: number;
25
+ }
26
+
27
+ /**
28
+ * Conjuncts verified for 'complete' coverage state.
29
+ *
30
+ * REQ-COVERAGE-STATE defines 'complete' as requiring FIVE conjuncts:
31
+ * 1. period wholly at/after attestedAtMs (VERIFIED in node 4)
32
+ * 2. period wholly inside retained history (DEFERRED to node 5)
33
+ * 3. all records match attested schema (DEFERRED to node 5)
34
+ * 4. all records match attested build provenance (DEFERRED to node 5)
35
+ * 5. no known gap in the period (DEFERRED to node 5)
36
+ *
37
+ * This type-level marker prevents node 5's integrator from misreading
38
+ * 'complete' as fully verified when only the period-timing conjunct has
39
+ * been checked. Node 5 must wire the remaining four and update this field.
40
+ */
41
+ export type VerifiedConjunct =
42
+ | "period-timing"
43
+ | "retained-history"
44
+ | "schema-match"
45
+ | "build-match"
46
+ | "no-gap";
47
+
48
+ export interface CoverageResult {
49
+ readonly state: CoverageState;
50
+ readonly reason?: string;
51
+ /**
52
+ * Which coverage conjuncts were actually verified.
53
+ *
54
+ * A 'complete' result from node 4 carries ONLY ["period-timing"].
55
+ * Node 5 must add the remaining four before emitting a total.
56
+ */
57
+ readonly verifiedConjuncts: readonly VerifiedConjunct[];
58
+ }
59
+
60
+ export type AttestationRejectionReason =
61
+ | "symlink"
62
+ | "not-regular-file"
63
+ | "insecure-mode"
64
+ | "wrong-owner"
65
+ | "outside-base-dir"
66
+ | "oversize"
67
+ | "malformed"
68
+ | "version-mismatch"
69
+ | "schema-mismatch"
70
+ | "build-mismatch"
71
+ | "future-attested-at"
72
+ | "missing"
73
+ | "symlinked-base-dir";
74
+
75
+ export type AttestationResult =
76
+ | { readonly ok: true; readonly attestation: CoverageAttestation }
77
+ | { readonly ok: false; readonly reason: AttestationRejectionReason };
78
+
79
+ /**
80
+ * Read and validate the fleet-currency attestation file.
81
+ *
82
+ * REQ-COVERAGE-STATE: 0013 NEVER creates, modifies, or deletes this file.
83
+ * This is a READ-ONLY validator.
84
+ *
85
+ * Valid ONLY when ALL of these hold:
86
+ * - a bounded regular file (NOT a symlink)
87
+ * - owned by the current user
88
+ * - mode 0600
89
+ * - beneath the owner-only machine-global extension directory
90
+ * - contains exactly: a supported attestation version, the current history
91
+ * schema version, the current canonical emitter build identity, and a
92
+ * finite non-future attestedAtMs
93
+ *
94
+ * Missing, malformed, insecure, or mismatched -> rollout currency UNPROVEN.
95
+ *
96
+ * SECURITY NOTE: Uses lstatSync (NOT statSync) to detect symlinks without
97
+ * following them, plus realpathSync containment check. Mirrors the pattern
98
+ * from history-store.ts validateAppendPath.
99
+ */
100
+ export function readCoverageAttestation(
101
+ extensionDir?: string,
102
+ ): AttestationResult {
103
+ // Package storage identity stays decoupled from the logical provider ID.
104
+ const baseDir =
105
+ extensionDir ??
106
+ join(
107
+ process.env.PI_CODING_AGENT_DIR ??
108
+ join(process.env.HOME ?? ".", ".pi", "agent"),
109
+ "pi-multi-account",
110
+ );
111
+
112
+ const attestationPath = join(baseDir, "history-coverage-attestation.json");
113
+
114
+ try {
115
+ // REQ-STORE-PERMISSIONS: Reject symlinked base directory
116
+ // If baseDir is a symlink pointing at a victim directory, an attacker
117
+ // could place a valid-looking attestation there and bypass currency checks.
118
+ const baseDirStats = lstatSync(baseDir);
119
+ if (baseDirStats.isSymbolicLink()) {
120
+ return { ok: false, reason: "symlinked-base-dir" };
121
+ }
122
+
123
+ const resolvedBase = realpathSync(baseDir);
124
+
125
+ // REQ-COVERAGE-STATE: must be a bounded regular NON-SYMLINK
126
+ const stats = lstatSync(attestationPath);
127
+
128
+ if (stats.isSymbolicLink()) {
129
+ // Symlink - REJECT without following
130
+ return { ok: false, reason: "symlink" };
131
+ }
132
+
133
+ if (!stats.isFile()) {
134
+ return { ok: false, reason: "not-regular-file" };
135
+ }
136
+
137
+ // Bounded size check
138
+ if (stats.size > MAX_ATTESTATION_BYTES) {
139
+ return { ok: false, reason: "oversize" };
140
+ }
141
+
142
+ // REQ-COVERAGE-STATE: must be owned by the current user
143
+ if (stats.uid !== process.getuid?.()) {
144
+ return { ok: false, reason: "wrong-owner" };
145
+ }
146
+
147
+ // REQ-COVERAGE-STATE: must be mode 0600
148
+ // biome-ignore lint/suspicious/noMagicNumbers: POSIX permission bits
149
+ if ((stats.mode & 0o777) !== 0o600) {
150
+ return { ok: false, reason: "insecure-mode" };
151
+ }
152
+
153
+ // REQ-STORE-PERMISSIONS: containment check - must be within baseDir
154
+ //
155
+ // DEFENSE-IN-DEPTH: This guard is unreachable via normal filesystem operations
156
+ // given the guard ordering above. Since attestationPath is constructed as
157
+ // join(baseDir, "history-coverage-attestation.json"), and both paths are
158
+ // resolved through realpathSync:
159
+ // - If baseDir itself is a symlink → caught by symlinked-base-dir guard
160
+ // - If attestationPath is a symlink → caught by isSymbolicLink() guard
161
+ // - If a parent component of baseDir is a symlink → both paths resolve
162
+ // through it and remain contained
163
+ //
164
+ // This check protects against implementation bugs in realpathSync/relative
165
+ // or exotic filesystem features, but cannot be triggered through standard
166
+ // symlink-based directory traversal.
167
+ const resolvedAttestation = realpathSync(attestationPath);
168
+ const rel = relative(resolvedBase, resolvedAttestation);
169
+
170
+ // Valid if:
171
+ // - Non-empty (not the base itself)
172
+ // - Doesn't start with '..' (not outside base)
173
+ // - Not absolute (different roots)
174
+ if (
175
+ !rel ||
176
+ rel.startsWith("..") ||
177
+ relative(resolvedBase, resolvedAttestation).startsWith("/")
178
+ ) {
179
+ return { ok: false, reason: "outside-base-dir" };
180
+ }
181
+
182
+ // Read and parse
183
+ const raw = readFileSync(attestationPath, "utf8");
184
+ const parsed: unknown = JSON.parse(raw);
185
+
186
+ if (
187
+ typeof parsed !== "object" ||
188
+ parsed === null ||
189
+ Array.isArray(parsed)
190
+ ) {
191
+ return { ok: false, reason: "malformed" };
192
+ }
193
+
194
+ const record = parsed as Record<string, unknown>;
195
+
196
+ // REQ-COVERAGE-STATE: validate required fields
197
+ if (
198
+ typeof record.attestationVersion !== "number" ||
199
+ typeof record.historySchemaVersion !== "number" ||
200
+ typeof record.buildProvenance !== "string" ||
201
+ typeof record.attestedAtMs !== "number"
202
+ ) {
203
+ return { ok: false, reason: "malformed" };
204
+ }
205
+
206
+ // REQ-COVERAGE-STATE: supported attestation version
207
+ if (record.attestationVersion !== SUPPORTED_ATTESTATION_VERSION) {
208
+ return { ok: false, reason: "version-mismatch" };
209
+ }
210
+
211
+ // REQ-COVERAGE-STATE: must match current history schema version
212
+ if (record.historySchemaVersion !== HISTORY_SCHEMA_VERSION) {
213
+ return { ok: false, reason: "schema-mismatch" };
214
+ }
215
+
216
+ // REQ-COVERAGE-STATE: must match current canonical build identity
217
+ if (record.buildProvenance !== CANONICAL_BUILD_IDENTITY) {
218
+ return { ok: false, reason: "build-mismatch" };
219
+ }
220
+
221
+ // REQ-COVERAGE-STATE: finite non-future attestedAtMs
222
+ if (
223
+ !Number.isFinite(record.attestedAtMs) ||
224
+ record.attestedAtMs < 0 ||
225
+ record.attestedAtMs > Date.now()
226
+ ) {
227
+ return { ok: false, reason: "future-attested-at" };
228
+ }
229
+
230
+ return {
231
+ ok: true,
232
+ attestation: {
233
+ attestationVersion: record.attestationVersion,
234
+ historySchemaVersion: record.historySchemaVersion,
235
+ buildProvenance: record.buildProvenance,
236
+ attestedAtMs: record.attestedAtMs,
237
+ },
238
+ };
239
+ } catch (err) {
240
+ // JSON parse errors return malformed; file not found returns missing
241
+ if (err instanceof SyntaxError) {
242
+ return { ok: false, reason: "malformed" };
243
+ }
244
+ return { ok: false, reason: "missing" };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Compute coverage state for a requested time period.
250
+ *
251
+ * REQ-COVERAGE-STATE coverage states:
252
+ * - `unknown`: rollout currency or fleet membership unproven (no attestation,
253
+ * or attestation invalid/mismatched)
254
+ * - `partial`: valid attestation exists BUT one or more of the five required
255
+ * conjuncts is unverified (period timing, retained history, schema match,
256
+ * build match, no gap)
257
+ * - `complete`: ONLY when the period lies wholly at or after attestedAtMs,
258
+ * wholly inside retained history, all records match attested schema and
259
+ * build provenance, and no known gap exists
260
+ *
261
+ * No unqualified total may be emitted unless coverage is `complete`.
262
+ *
263
+ * Implementation verifies ALL FIVE conjuncts:
264
+ * 1. period-timing: period wholly at/after attestedAtMs
265
+ * 2. retained-history: period wholly inside retained history
266
+ * 3. schema-match: all records in period match attested schema
267
+ * 4. build-match: all records match attested build provenance
268
+ * 5. no-gap: no recorded gap overlaps the period
269
+ */
270
+ export function computeCoverageState(
271
+ periodStartMs: number,
272
+ periodEndMs: number,
273
+ extensionDir?: string,
274
+ ): CoverageResult {
275
+ const result = readCoverageAttestation(extensionDir);
276
+
277
+ if (!result.ok) {
278
+ return {
279
+ state: "unknown",
280
+ reason: `attestation rejected: ${result.reason}`,
281
+ verifiedConjuncts: [],
282
+ };
283
+ }
284
+
285
+ const attestation = result.attestation;
286
+ const verifiedConjuncts: VerifiedConjunct[] = [];
287
+
288
+ // CONJUNCT 1: period-timing - period must be wholly at or after attestedAtMs
289
+ if (periodStartMs < attestation.attestedAtMs) {
290
+ return {
291
+ state: "partial",
292
+ reason: "period starts before attestedAtMs",
293
+ verifiedConjuncts: ["period-timing"],
294
+ };
295
+ }
296
+ verifiedConjuncts.push("period-timing");
297
+
298
+ // Scan both logs once. Keep only the conjunct evidence needed by this period.
299
+ const stores: ReadonlyArray<{
300
+ readonly recordType: "window-sample" | "cost-delta";
301
+ readonly options: Parameters<typeof iterateHistory>[1];
302
+ }> = [
303
+ {
304
+ recordType: "window-sample",
305
+ options: extensionDir
306
+ ? { windowHistoryPath: join(extensionDir, "window-history.ndjson") }
307
+ : undefined,
308
+ },
309
+ {
310
+ recordType: "cost-delta",
311
+ options: extensionDir
312
+ ? { costHistoryPath: join(extensionDir, "cost-history.ndjson") }
313
+ : undefined,
314
+ },
315
+ ];
316
+ let oldestRecordedAtMs = Number.POSITIVE_INFINITY;
317
+ let recordFailure: string | undefined;
318
+ let gapFailure: string | undefined;
319
+ for (const store of stores) {
320
+ for (const record of iterateHistory(store.recordType, store.options)) {
321
+ if (isRecordEnvelope(record)) {
322
+ if (record.recordedAtMs < oldestRecordedAtMs) {
323
+ oldestRecordedAtMs = record.recordedAtMs;
324
+ }
325
+ if (
326
+ recordFailure === undefined &&
327
+ record.recordedAtMs >= periodStartMs &&
328
+ record.recordedAtMs <= periodEndMs
329
+ ) {
330
+ if (record.schemaVersion !== attestation.historySchemaVersion) {
331
+ recordFailure = `record at ${record.recordedAtMs} has schema ${record.schemaVersion}, expected ${attestation.historySchemaVersion}`;
332
+ } else {
333
+ const payload = record.payload as { buildProvenance?: unknown };
334
+ if (typeof payload !== "object" || payload === null) {
335
+ recordFailure = `record at ${record.recordedAtMs} has invalid payload`;
336
+ } else if (
337
+ payload.buildProvenance !== attestation.buildProvenance
338
+ ) {
339
+ recordFailure = `record at ${record.recordedAtMs} has build ${payload.buildProvenance}, expected ${attestation.buildProvenance}`;
340
+ }
341
+ }
342
+ }
343
+ } else if (
344
+ gapFailure === undefined &&
345
+ isGapRecord(record) &&
346
+ record.gapStartMs <= periodEndMs &&
347
+ record.gapEndMs >= periodStartMs
348
+ ) {
349
+ gapFailure = `gap [${record.gapStartMs}, ${record.gapEndMs}] overlaps period`;
350
+ }
351
+ }
352
+ }
353
+
354
+ if (!Number.isFinite(oldestRecordedAtMs)) {
355
+ return {
356
+ state: "partial",
357
+ reason: "no retained history records found",
358
+ verifiedConjuncts,
359
+ };
360
+ }
361
+ if (periodStartMs < oldestRecordedAtMs) {
362
+ return {
363
+ state: "partial",
364
+ reason: "period starts before oldest retained record",
365
+ verifiedConjuncts,
366
+ };
367
+ }
368
+ verifiedConjuncts.push("retained-history");
369
+ if (recordFailure !== undefined) {
370
+ return { state: "partial", reason: recordFailure, verifiedConjuncts };
371
+ }
372
+ verifiedConjuncts.push("schema-match");
373
+ verifiedConjuncts.push("build-match");
374
+ if (gapFailure !== undefined) {
375
+ return { state: "partial", reason: gapFailure, verifiedConjuncts };
376
+ }
377
+ verifiedConjuncts.push("no-gap");
378
+
379
+ // All five conjuncts verified - coverage is complete
380
+ return {
381
+ state: "complete",
382
+ verifiedConjuncts,
383
+ };
384
+ }
385
+
386
+ // Type guards for discriminating HistoryLogRecord
387
+ function isRecordEnvelope(
388
+ record: HistoryLogRecord,
389
+ ): record is HistoryRecordEnvelope {
390
+ return "recordType" in record && record.recordType !== "gap";
391
+ }
392
+
393
+ function isGapRecord(record: HistoryLogRecord): record is HistoryGapRecord {
394
+ return "recordType" in record && record.recordType === "gap";
395
+ }
396
+
397
+ export const COVERAGE_CANONICAL_BUILD_IDENTITY = CANONICAL_BUILD_IDENTITY;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Credential lifecycle integrity.
3
+ *
4
+ * Three defects surfaced by reading the Sarrius reference implementation
5
+ * (`.scratch/reference/code/pi/pi-multi-account-sarrius`), whose doc comments
6
+ * record the production failures each one caused. They share a root cause:
7
+ * treating a credential as if it were the account. A credential is a rotating
8
+ * artifact; the account behind it is what routing decisions actually care
9
+ * about.
10
+ *
11
+ * Nothing here reads from a store, persists, or logs a credential value. The
12
+ * pure merge helper only reassembles fields. The Antigravity projector copies
13
+ * its reviewed named fields into a short-lived callback input and drops every
14
+ * unknown field; neither helper retains or emits credential material. Every
15
+ * other helper takes bounded metadata (expiry, token presence).
16
+ */
17
+
18
+ import type { OAuthCredentials } from "@earendil-works/pi-ai";
19
+
20
+ /** Named credential fields reviewed for the upstream Antigravity OAuth boundary. */
21
+ export interface AntigravityOAuthCredential extends OAuthCredentials {
22
+ readonly projectId?: string;
23
+ readonly email?: string;
24
+ }
25
+
26
+ /**
27
+ * Copies only fields used by the selected Antigravity auth primitives.
28
+ * Unknown AuthStorage fields must not cross into login, refresh, or request auth.
29
+ */
30
+ export function projectAntigravityOAuthCredential(
31
+ credential: OAuthCredentials,
32
+ ): AntigravityOAuthCredential {
33
+ const projectId = credential["projectId"];
34
+ const email = credential["email"];
35
+ return {
36
+ access: credential.access,
37
+ refresh: credential.refresh,
38
+ expires: credential.expires,
39
+ ...(typeof projectId === "string" ? { projectId } : {}),
40
+ ...(typeof email === "string" ? { email } : {}),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Bounded, value-free description of a stored credential, sufficient to decide
46
+ * whether an account can serve a request. Deliberately not the credential:
47
+ * callers pass presence and expiry, never secrets.
48
+ */
49
+ export interface CredentialUsability {
50
+ /** Epoch ms when the credential expires, when the store reports one. */
51
+ readonly expiresAtMs?: number | undefined;
52
+ /** Whether a refresh token exists, i.e. whether expiry is recoverable. */
53
+ readonly hasRefreshToken: boolean;
54
+ }
55
+
56
+ /**
57
+ * Merges a refreshed OAuth credential onto the stored one.
58
+ *
59
+ * Spreading the refreshed fields last is the entire point: it carries the NEW
60
+ * access token and expiry onto the credential the host uses next. Returning the
61
+ * provider's response raw instead — which is what our Codex bridge did — loses
62
+ * any field the response omits. The refresh token is the dangerous one: many
63
+ * providers mint a new access token while returning no replacement refresh
64
+ * token, so a raw return silently drops the only means of future recovery.
65
+ *
66
+ * Sarrius documents shipping exactly this bug: the dropped merge made every
67
+ * post-refresh call reuse a stale access token and 401 forever, after which
68
+ * their consecutive-401 guard concluded the account was dead and killed a slot
69
+ * that was in fact healthy. A refresh that destroys the refresh token converts
70
+ * a recoverable credential into an unrecoverable one.
71
+ *
72
+ * Preserves the stored refresh token when the response carries none, and is
73
+ * conservative about what counts as "carries one" — a blank or whitespace-only
74
+ * value is treated as absent rather than allowed to overwrite a working token.
75
+ */
76
+ export function mergeRefreshedCredentials<
77
+ TStored extends Record<string, unknown>,
78
+ TRefreshed extends Record<string, unknown>,
79
+ >(stored: TStored, refreshed: TRefreshed): TStored & TRefreshed {
80
+ const refreshedPresent = Object.fromEntries(
81
+ Object.entries(refreshed).filter(
82
+ ([, value]) => value !== undefined && value !== null,
83
+ ),
84
+ ) as TRefreshed;
85
+ const merged = { ...stored, ...refreshedPresent } as TStored & TRefreshed;
86
+ const mintedRefresh = refreshed["refresh"];
87
+ const hasMintedRefresh =
88
+ typeof mintedRefresh === "string" && mintedRefresh.trim().length > 0;
89
+ if (hasMintedRefresh) return merged;
90
+ const storedRefresh = stored["refresh"];
91
+ if (typeof storedRefresh !== "string" || storedRefresh.length === 0) {
92
+ return merged;
93
+ }
94
+ return { ...merged, refresh: storedRefresh };
95
+ }
96
+
97
+ /**
98
+ * Whether a credential can still serve a request.
99
+ *
100
+ * Unusable means provably dead, not merely stale: the credential has expired
101
+ * AND carries no refresh token, so no code path can revive it. Such an account
102
+ * must leave the routing pool, because every dispatch to it is a guaranteed
103
+ * failure that consumes a turn and can trip failure heuristics against an
104
+ * account whose only problem is that nobody has logged in.
105
+ *
106
+ * A credential that is expired but refreshable stays usable — refreshing is the
107
+ * normal path and happens transparently. A credential with unknown expiry also
108
+ * stays usable: absent evidence, the routing pool is the safer default, and a
109
+ * real failure will still be classified reactively.
110
+ */
111
+ export function isCredentialUsable(
112
+ credential: CredentialUsability,
113
+ nowMs: number,
114
+ ): boolean {
115
+ const { expiresAtMs, hasRefreshToken } = credential;
116
+ if (hasRefreshToken) return true;
117
+ if (expiresAtMs === undefined || !Number.isFinite(expiresAtMs)) return true;
118
+ return expiresAtMs > nowMs;
119
+ }
120
+
121
+ /** How an account's identity compares to the one previously observed. */
122
+ export type AccountIdentityChange =
123
+ /** No prior observation; nothing can be concluded yet. */
124
+ | "first-observation"
125
+ /** Same real account. A token may have rotated; the account did not. */
126
+ | "unchanged"
127
+ /** The slot now holds a genuinely different real account. */
128
+ | "changed"
129
+ /** Identity is not derivable, so no change can be proven. */
130
+ | "indeterminate";
131
+
132
+ /**
133
+ * Classifies an account identity transition for a slot.
134
+ *
135
+ * Routing state — rate-limit cooldowns and failure records — belongs to the
136
+ * ACCOUNT, not to the credential that happens to represent it. A routine OAuth
137
+ * refresh rotates the access token while the account behind it is unchanged, so
138
+ * discarding that state on every rotation is wrong twice over: a server-side
139
+ * rate limit is not lifted by minting a new token, and an agent that forgets
140
+ * the cooldown will route straight back into the limit it just hit.
141
+ *
142
+ * When identity cannot be derived — Anthropic issues opaque tokens carrying no
143
+ * account claim — the answer is `indeterminate`, never a guess. Callers must
144
+ * treat that as "cannot prove a change" and retain existing state. Erring
145
+ * toward keeping a cooldown costs at most some delay against one account;
146
+ * erring toward clearing it sends real traffic into a known-limited account.
147
+ */
148
+ export function classifyAccountIdentity(
149
+ previous: string | undefined,
150
+ next: string | undefined,
151
+ ): AccountIdentityChange {
152
+ if (previous === undefined && next === undefined) return "indeterminate";
153
+ if (next === undefined) return "indeterminate";
154
+ if (previous === undefined) return "first-observation";
155
+ return previous === next ? "unchanged" : "changed";
156
+ }
157
+
158
+ /**
159
+ * Whether a slot's accumulated routing state may be discarded.
160
+ *
161
+ * True only for a proven account substitution. Every other case — same account,
162
+ * first sighting, or underivable identity — retains state, because none of them
163
+ * is evidence that the server-side condition which created it has cleared.
164
+ */
165
+ export function shouldClearAccountState(
166
+ change: AccountIdentityChange,
167
+ ): boolean {
168
+ return change === "changed";
169
+ }