@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.
- package/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- 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
|
+
}
|