@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,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forces one credential refresh when a provider says 401 while the local
|
|
3
|
+
* credential still looks valid.
|
|
4
|
+
*
|
|
5
|
+
* Pi refreshes an OAuth credential only after LOCAL expiry. A server-side early
|
|
6
|
+
* revocation therefore leaves a token that looks fine locally and 401s on every
|
|
7
|
+
* request, forever, with nothing triggering a refresh. The warmer only warms
|
|
8
|
+
* near-expiry credentials, so it never touches this case either.
|
|
9
|
+
*
|
|
10
|
+
* Ported from the Sarrius reference (index.ts:2576-2646), with one structural
|
|
11
|
+
* improvement: persistence goes through `AuthStorage.modify()`, whose file
|
|
12
|
+
* backend does a locked read-modify-write, so the cross-process race Sarrius
|
|
13
|
+
* handles by hand is handled by the host. Sarrius's sharpest check is kept: a
|
|
14
|
+
* refresh that returns a DIFFERENT account identity is terminal, because the
|
|
15
|
+
* slot is now a different account and continuing would silently use credentials
|
|
16
|
+
* the operator did not intend.
|
|
17
|
+
*
|
|
18
|
+
* CREDENTIAL DISCIPLINE. Credential values transit the `modify` callback and
|
|
19
|
+
* the family refresher, and nowhere else: they never enter fields on this
|
|
20
|
+
* class, never reach diagnostics, and never appear in a return value. Callers
|
|
21
|
+
* receive only a bounded outcome string.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { DiagnosticLog } from "./diagnostics.js";
|
|
25
|
+
import type { AllowedFamily } from "./config.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The slice of a stored OAuth credential this module touches. Matches the
|
|
29
|
+
* host's stored shape; extra fields pass through `modify` untouched.
|
|
30
|
+
*/
|
|
31
|
+
export interface RefreshableCredential {
|
|
32
|
+
readonly type?: string;
|
|
33
|
+
readonly access?: string;
|
|
34
|
+
readonly refresh?: string;
|
|
35
|
+
readonly expires?: number;
|
|
36
|
+
readonly accountId?: string;
|
|
37
|
+
readonly [key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Complete input required by both maintained family refresh callbacks. */
|
|
41
|
+
export interface OAuthRefreshCredential extends RefreshableCredential {
|
|
42
|
+
readonly type: "oauth";
|
|
43
|
+
readonly access: string;
|
|
44
|
+
readonly refresh: string;
|
|
45
|
+
readonly expires: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Refreshes one family's credential; the captured upstream oauth surface. */
|
|
49
|
+
export type FamilyRefresher = (
|
|
50
|
+
credentials: OAuthRefreshCredential,
|
|
51
|
+
) => Promise<RefreshableCredential>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `CredentialStore.modify` shape, injected so tests never construct a real
|
|
55
|
+
* store and live wiring can bind the host's already-open locked store.
|
|
56
|
+
*/
|
|
57
|
+
export type AuthModify = (
|
|
58
|
+
provider: string,
|
|
59
|
+
fn: (
|
|
60
|
+
current: RefreshableCredential | undefined,
|
|
61
|
+
) => Promise<RefreshableCredential | undefined>,
|
|
62
|
+
) => Promise<RefreshableCredential | undefined>;
|
|
63
|
+
|
|
64
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === "object" && value !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isOAuthRefreshCredential(
|
|
69
|
+
value: RefreshableCredential | undefined,
|
|
70
|
+
): value is OAuthRefreshCredential {
|
|
71
|
+
return (
|
|
72
|
+
value?.type === "oauth" &&
|
|
73
|
+
typeof value.access === "string" &&
|
|
74
|
+
value.access.length > 0 &&
|
|
75
|
+
typeof value.refresh === "string" &&
|
|
76
|
+
value.refresh.length > 0 &&
|
|
77
|
+
typeof value.expires === "number" &&
|
|
78
|
+
Number.isFinite(value.expires)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function familyRefresher(oauth: unknown): FamilyRefresher | undefined {
|
|
83
|
+
const refreshToken = isRecord(oauth) ? oauth.refreshToken : undefined;
|
|
84
|
+
if (typeof refreshToken !== "function") return undefined;
|
|
85
|
+
return async (credential) => {
|
|
86
|
+
const refreshed: unknown = await refreshToken.call(oauth, credential);
|
|
87
|
+
return isRecord(refreshed) ? refreshed : {};
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface HostForcedCredentialRefresherOptions {
|
|
92
|
+
readonly modelRegistry: unknown;
|
|
93
|
+
readonly oauthByFamily: Partial<Record<AllowedFamily, unknown>>;
|
|
94
|
+
readonly diagnostics: Pick<DiagnosticLog, "record">;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Binds the maintained OAuth callbacks to Pi's already-open credential store.
|
|
99
|
+
*
|
|
100
|
+
* `ModelRegistry.runtime` and `ModelRuntime.credentials` are private in the
|
|
101
|
+
* locked Pi 0.84.4 declaration files, while that exact installed runtime owns
|
|
102
|
+
* both fields. `RuntimeCredentials` forwards `modify` to `AuthStorage.modify`,
|
|
103
|
+
* whose file backend holds a cross-process lock for the whole async
|
|
104
|
+
* read-modify-write. If that verified host shape changes, this returns
|
|
105
|
+
* undefined and 401 handling fails closed into the existing
|
|
106
|
+
* invalidate-and-route path.
|
|
107
|
+
*/
|
|
108
|
+
export function createHostForcedCredentialRefresher(
|
|
109
|
+
options: HostForcedCredentialRefresherOptions,
|
|
110
|
+
): ForcedCredentialRefresher | undefined {
|
|
111
|
+
const credentials = (
|
|
112
|
+
options.modelRegistry as
|
|
113
|
+
| {
|
|
114
|
+
runtime?: {
|
|
115
|
+
credentials?: { modify?: unknown };
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
| undefined
|
|
119
|
+
)?.runtime?.credentials;
|
|
120
|
+
if (!credentials || typeof credentials.modify !== "function") return undefined;
|
|
121
|
+
|
|
122
|
+
const refreshers: Partial<Record<AllowedFamily, FamilyRefresher>> = {};
|
|
123
|
+
for (const family of [
|
|
124
|
+
"anthropic",
|
|
125
|
+
"openai-codex",
|
|
126
|
+
"google-antigravity",
|
|
127
|
+
] as const) {
|
|
128
|
+
const refresh = familyRefresher(options.oauthByFamily[family]);
|
|
129
|
+
if (refresh !== undefined) refreshers[family] = refresh;
|
|
130
|
+
}
|
|
131
|
+
return new ForcedCredentialRefresher({
|
|
132
|
+
modify: credentials.modify.bind(credentials) as AuthModify,
|
|
133
|
+
refreshers,
|
|
134
|
+
diagnostics: options.diagnostics,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type ForcedRefreshOutcome =
|
|
139
|
+
/** A refreshed credential was persisted; the account deserves a fresh chance. */
|
|
140
|
+
| "refreshed"
|
|
141
|
+
/** The refresh identified a DIFFERENT account; invalidation must stand. */
|
|
142
|
+
| "identity-changed"
|
|
143
|
+
/** The refresh failed; nothing was persisted. */
|
|
144
|
+
| "failed"
|
|
145
|
+
/** No stored oauth credential with a refresh token; nothing to force. */
|
|
146
|
+
| "unsupported"
|
|
147
|
+
/** This provider already got its one forced attempt this session. */
|
|
148
|
+
| "already-attempted";
|
|
149
|
+
|
|
150
|
+
function recordOutcome(
|
|
151
|
+
diagnostics: Pick<DiagnosticLog, "record">,
|
|
152
|
+
providerId: string,
|
|
153
|
+
outcome: ForcedRefreshOutcome,
|
|
154
|
+
): ForcedRefreshOutcome {
|
|
155
|
+
diagnostics.record(
|
|
156
|
+
outcome === "refreshed" ? "info" : "warning",
|
|
157
|
+
"credential.force-refresh",
|
|
158
|
+
outcome === "refreshed"
|
|
159
|
+
? "Forced one credential refresh after an explicit provider 401."
|
|
160
|
+
: "A forced credential refresh did not recover the provider; normal invalidation remains active.",
|
|
161
|
+
{ providerId, outcome },
|
|
162
|
+
);
|
|
163
|
+
return outcome;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ForcedCredentialRefresherOptions {
|
|
167
|
+
readonly modify: AuthModify;
|
|
168
|
+
readonly refreshers: Partial<Record<AllowedFamily, FamilyRefresher>>;
|
|
169
|
+
readonly diagnostics: Pick<DiagnosticLog, "record">;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class ForcedCredentialRefresher {
|
|
173
|
+
readonly #modify: AuthModify;
|
|
174
|
+
readonly #refreshers: Partial<Record<AllowedFamily, FamilyRefresher>>;
|
|
175
|
+
readonly #diagnostics: Pick<DiagnosticLog, "record">;
|
|
176
|
+
/**
|
|
177
|
+
* Exactly one forced attempt per provider per session, whatever the outcome.
|
|
178
|
+
* A 401 that survives a fresh token is not a staleness problem, and retrying
|
|
179
|
+
* the refresh on every exhausted turn would hammer the provider's token
|
|
180
|
+
* endpoint with requests that cannot succeed.
|
|
181
|
+
*/
|
|
182
|
+
readonly #attempted = new Set<string>();
|
|
183
|
+
|
|
184
|
+
constructor(options: ForcedCredentialRefresherOptions) {
|
|
185
|
+
this.#modify = options.modify;
|
|
186
|
+
this.#refreshers = options.refreshers;
|
|
187
|
+
this.#diagnostics = options.diagnostics;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async attempt(
|
|
191
|
+
providerId: string,
|
|
192
|
+
family: AllowedFamily,
|
|
193
|
+
): Promise<ForcedRefreshOutcome> {
|
|
194
|
+
if (this.#attempted.has(providerId)) return "already-attempted";
|
|
195
|
+
this.#attempted.add(providerId);
|
|
196
|
+
|
|
197
|
+
const refresher = this.#refreshers[family];
|
|
198
|
+
if (refresher === undefined) return "unsupported";
|
|
199
|
+
|
|
200
|
+
let outcome: ForcedRefreshOutcome = "failed";
|
|
201
|
+
try {
|
|
202
|
+
await this.#modify(providerId, async (current) => {
|
|
203
|
+
if (!isOAuthRefreshCredential(current)) {
|
|
204
|
+
outcome = "unsupported";
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
let refreshed: RefreshableCredential;
|
|
208
|
+
try {
|
|
209
|
+
refreshed = await refresher(current);
|
|
210
|
+
} catch {
|
|
211
|
+
// The refresh itself failed. Persist nothing; the 401 keeps its
|
|
212
|
+
// terminal classification. Never record the provider's error body:
|
|
213
|
+
// OAuth endpoints can echo credential material.
|
|
214
|
+
outcome = "failed";
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
typeof current.accountId === "string" &&
|
|
219
|
+
typeof refreshed.accountId === "string" &&
|
|
220
|
+
current.accountId !== refreshed.accountId
|
|
221
|
+
) {
|
|
222
|
+
// The slot now resolves to a different account. Persisting would
|
|
223
|
+
// silently authenticate as an account the operator did not choose --
|
|
224
|
+
// the exact concurrency failure this extension exists to avoid.
|
|
225
|
+
outcome = "identity-changed";
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const merged: RefreshableCredential = {
|
|
229
|
+
...current,
|
|
230
|
+
...refreshed,
|
|
231
|
+
type: "oauth",
|
|
232
|
+
};
|
|
233
|
+
if (!isOAuthRefreshCredential(merged)) {
|
|
234
|
+
outcome = "failed";
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
outcome = "refreshed";
|
|
238
|
+
return merged;
|
|
239
|
+
});
|
|
240
|
+
} catch {
|
|
241
|
+
// Record only bounded metadata. The thrown value can originate in an OAuth
|
|
242
|
+
// implementation and is therefore not safe diagnostic material.
|
|
243
|
+
outcome = "failed";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return recordOutcome(this.#diagnostics, providerId, outcome);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { acquireMachineLease } from "./machine-lease.js";
|
|
16
|
+
import { getPeriodBounds } from "./period-boundaries.js";
|
|
17
|
+
|
|
18
|
+
const SCHEMA_VERSION = 1;
|
|
19
|
+
export const MAX_MARKER_BYTES = 1_024;
|
|
20
|
+
const LEASE_TTL_MS = 5_000;
|
|
21
|
+
|
|
22
|
+
export type DeclarationNoticeCondition = "stale" | "not-installed";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The only condition values that may ever reach the persisted marker.
|
|
26
|
+
*
|
|
27
|
+
* TypeScript's `DeclarationNoticeCondition` union is erased at runtime, so a
|
|
28
|
+
* JavaScript or casted caller could otherwise pass an arbitrary string that
|
|
29
|
+
* would be serialized into `declaration-notice.json`. This allow-list is the
|
|
30
|
+
* runtime boundary that keeps a caller from causing a path, identifier, or
|
|
31
|
+
* credential-like value to be retained (AC-6): anything outside it is rejected
|
|
32
|
+
* before the lease is acquired or any byte is written.
|
|
33
|
+
*/
|
|
34
|
+
const ALLOWED_CONDITIONS: ReadonlySet<DeclarationNoticeCondition> = new Set([
|
|
35
|
+
"stale",
|
|
36
|
+
"not-installed",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function isAllowedCondition(
|
|
40
|
+
value: unknown,
|
|
41
|
+
): value is DeclarationNoticeCondition {
|
|
42
|
+
return ALLOWED_CONDITIONS.has(value as DeclarationNoticeCondition);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
interface DeclarationNoticeState {
|
|
47
|
+
readonly schemaVersion: 1;
|
|
48
|
+
readonly dayStartMs: number;
|
|
49
|
+
readonly condition: DeclarationNoticeCondition;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type MarkerReadResult =
|
|
53
|
+
| { readonly status: "valid"; readonly marker: DeclarationNoticeState }
|
|
54
|
+
| { readonly status: "missing" }
|
|
55
|
+
| { readonly status: "malformed" }
|
|
56
|
+
| { readonly status: "unavailable" };
|
|
57
|
+
|
|
58
|
+
function defaultPaths(): { readonly path: string; readonly lockPath: string } {
|
|
59
|
+
const agentDir =
|
|
60
|
+
process.env.PI_CODING_AGENT_DIR ??
|
|
61
|
+
join(process.env.HOME ?? ".", ".pi", "agent");
|
|
62
|
+
// Package storage identity stays decoupled from the logical provider ID.
|
|
63
|
+
const directory = join(agentDir, "pi-multi-account");
|
|
64
|
+
return {
|
|
65
|
+
path: join(directory, "declaration-notice.json"),
|
|
66
|
+
lockPath: join(directory, "declaration-notice.lock"),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseMarker(value: unknown): DeclarationNoticeState | undefined {
|
|
71
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const candidate = value as Record<string, unknown>;
|
|
75
|
+
if (
|
|
76
|
+
candidate.schemaVersion !== SCHEMA_VERSION ||
|
|
77
|
+
!Number.isSafeInteger(candidate.dayStartMs) ||
|
|
78
|
+
(candidate.dayStartMs as number) < 0 ||
|
|
79
|
+
(candidate.condition !== "stale" && candidate.condition !== "not-installed") ||
|
|
80
|
+
Object.keys(candidate).some(
|
|
81
|
+
(key) =>
|
|
82
|
+
key !== "schemaVersion" && key !== "dayStartMs" && key !== "condition",
|
|
83
|
+
)
|
|
84
|
+
) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
schemaVersion: SCHEMA_VERSION,
|
|
89
|
+
dayStartMs: candidate.dayStartMs as number,
|
|
90
|
+
condition: candidate.condition,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Persistent once-per-UTC-day marker for the session-start declaration notice. */
|
|
95
|
+
export class DeclarationNoticeMarker {
|
|
96
|
+
readonly #path: string;
|
|
97
|
+
readonly #lockPath: string;
|
|
98
|
+
readonly #now: () => number;
|
|
99
|
+
|
|
100
|
+
constructor(options: {
|
|
101
|
+
readonly path?: string;
|
|
102
|
+
readonly lockPath?: string;
|
|
103
|
+
readonly now?: () => number;
|
|
104
|
+
} = {}) {
|
|
105
|
+
const defaults = defaultPaths();
|
|
106
|
+
this.#path = options.path ?? defaults.path;
|
|
107
|
+
this.#lockPath = options.lockPath ?? defaults.lockPath;
|
|
108
|
+
this.#now = options.now ?? Date.now;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns true when the caller should notify. Contention and lease/read failures
|
|
113
|
+
* suppress; a post-decision marker write failure still returns true.
|
|
114
|
+
*/
|
|
115
|
+
shouldNotify(condition: DeclarationNoticeCondition): boolean {
|
|
116
|
+
// Reject any runtime value outside the allowed literals BEFORE acquiring
|
|
117
|
+
// the lease or writing, so a caller can never cause an arbitrary string to
|
|
118
|
+
// be persisted in the marker (AC-6).
|
|
119
|
+
if (!isAllowedCondition(condition)) return false;
|
|
120
|
+
let lease: ReturnType<typeof acquireMachineLease>;
|
|
121
|
+
try {
|
|
122
|
+
lease = acquireMachineLease({
|
|
123
|
+
lockPath: this.#lockPath,
|
|
124
|
+
ttlMs: LEASE_TTL_MS,
|
|
125
|
+
now: this.#now,
|
|
126
|
+
reclaimMalformed: true,
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (lease === undefined) return false;
|
|
132
|
+
try {
|
|
133
|
+
const todayStartMs = getPeriodBounds(this.#now(), "day").startMs;
|
|
134
|
+
const existing = this.#readMarker();
|
|
135
|
+
if (existing.status === "unavailable") return false;
|
|
136
|
+
if (
|
|
137
|
+
existing.status === "valid" &&
|
|
138
|
+
existing.marker.dayStartMs === todayStartMs
|
|
139
|
+
) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
this.#writeMarker({
|
|
143
|
+
schemaVersion: SCHEMA_VERSION,
|
|
144
|
+
dayStartMs: todayStartMs,
|
|
145
|
+
condition,
|
|
146
|
+
});
|
|
147
|
+
return true;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
} finally {
|
|
151
|
+
try {
|
|
152
|
+
lease.release();
|
|
153
|
+
} catch {
|
|
154
|
+
// A release failure cannot block session startup.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A matched session removes the prior notice marker so later drift can notify. */
|
|
160
|
+
clear(): void {
|
|
161
|
+
let lease: ReturnType<typeof acquireMachineLease>;
|
|
162
|
+
try {
|
|
163
|
+
lease = acquireMachineLease({
|
|
164
|
+
lockPath: this.#lockPath,
|
|
165
|
+
ttlMs: LEASE_TTL_MS,
|
|
166
|
+
now: this.#now,
|
|
167
|
+
reclaimMalformed: true,
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (lease === undefined) return;
|
|
173
|
+
try {
|
|
174
|
+
unlinkSync(this.#path);
|
|
175
|
+
} catch {
|
|
176
|
+
// Marker cleanup failures cannot block session startup.
|
|
177
|
+
} finally {
|
|
178
|
+
try {
|
|
179
|
+
lease.release();
|
|
180
|
+
} catch {
|
|
181
|
+
// A release failure cannot block session startup.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#readMarker(): MarkerReadResult {
|
|
187
|
+
try {
|
|
188
|
+
const stats = statSync(this.#path);
|
|
189
|
+
if (!stats.isFile() || stats.size > MAX_MARKER_BYTES) {
|
|
190
|
+
return { status: "malformed" };
|
|
191
|
+
}
|
|
192
|
+
const parsed = parseMarker(JSON.parse(readFileSync(this.#path, "utf8")));
|
|
193
|
+
return parsed === undefined
|
|
194
|
+
? { status: "malformed" }
|
|
195
|
+
: { status: "valid", marker: parsed };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
198
|
+
? { status: "missing" }
|
|
199
|
+
: error instanceof SyntaxError
|
|
200
|
+
? { status: "malformed" }
|
|
201
|
+
: { status: "unavailable" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#writeMarker(marker: DeclarationNoticeState): boolean {
|
|
206
|
+
const encoded = `${JSON.stringify(marker)}\n`;
|
|
207
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_MARKER_BYTES) return false;
|
|
208
|
+
const directory = dirname(this.#path);
|
|
209
|
+
const temporaryPath = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
|
|
210
|
+
let descriptor: number | undefined;
|
|
211
|
+
try {
|
|
212
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
213
|
+
chmodSync(directory, 0o700);
|
|
214
|
+
descriptor = openSync(temporaryPath, "wx", 0o600);
|
|
215
|
+
writeFileSync(descriptor, encoded, { encoding: "utf8" });
|
|
216
|
+
fsyncSync(descriptor);
|
|
217
|
+
closeSync(descriptor);
|
|
218
|
+
descriptor = undefined;
|
|
219
|
+
renameSync(temporaryPath, this.#path);
|
|
220
|
+
chmodSync(this.#path, 0o600);
|
|
221
|
+
return true;
|
|
222
|
+
} catch {
|
|
223
|
+
if (descriptor !== undefined) {
|
|
224
|
+
try {
|
|
225
|
+
closeSync(descriptor);
|
|
226
|
+
} catch {
|
|
227
|
+
// Continue with temporary-file cleanup.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
unlinkSync(temporaryPath);
|
|
232
|
+
} catch {
|
|
233
|
+
// The notice decision stays fail-soft when persistence fails.
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|