agl 40.4.0 → 40.5.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/changelog.md +33 -0
- package/dist_serve/bundle.js +2228 -2177
- package/dist_serve/bundle.js.map +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +2 -2
- package/dist_ts/classes.accountsactions.d.ts +11 -1
- package/dist_ts/classes.accountsactions.js +59 -2
- package/dist_ts/classes.controller.js +5 -5
- package/dist_ts/classes.flexaccountaccess.js +3 -2
- package/dist_ts/classes.harnessreleases.d.ts +14 -4
- package/dist_ts/classes.harnessreleases.js +28 -4
- package/dist_ts/classes.inferencegateway.d.ts +28 -3
- package/dist_ts/classes.inferencegateway.js +177 -61
- package/dist_ts/classes.inferenceplacement.d.ts +5 -3
- package/dist_ts/classes.inferenceplacement.js +2 -2
- package/dist_ts/classes.inferencerouter.d.ts +349 -91
- package/dist_ts/classes.inferencerouter.js +804 -297
- package/dist_ts/classes.inferenceservice.d.ts +14 -2
- package/dist_ts/classes.inferenceservice.js +53 -14
- package/dist_ts/classes.inferencespend.d.ts +339 -0
- package/dist_ts/classes.inferencespend.js +815 -0
- package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
- package/dist_ts/classes.inferenceusagecache.js +9 -5
- package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
- package/dist_ts/classes.inferenceusagetap.js +185 -31
- package/dist_ts/classes.proxycontrol.d.ts +3 -2
- package/dist_ts/classes.proxycontrol.js +1 -2
- package/dist_ts/classes.proxydaemon.js +3 -2
- package/dist_ts/classes.proxystore.d.ts +6 -0
- package/dist_ts/classes.proxystore.js +11 -2
- package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
- package/dist_ts/fleet/classes.claudeworkers.js +10 -9
- package/dist_ts/functions.claudecoderelease.d.ts +12 -6
- package/dist_ts/functions.claudecoderelease.js +13 -7
- package/dist_ts/functions.inferencecli.js +12 -8
- package/dist_ts/interfaces.auth.d.ts +1 -1
- package/dist_ts/interfaces.auth.js +3 -1
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +7 -2
- package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
- package/dist_ts_authswitch/authority-apikeys.js +65 -0
- package/dist_ts_authswitch/authority-contract.d.ts +172 -33
- package/dist_ts_authswitch/authority-contract.js +54 -1
- package/dist_ts_authswitch/authority-overview.js +8 -1
- package/dist_ts_authswitch/authority-placement.d.ts +7 -2
- package/dist_ts_authswitch/authority-placement.js +10 -3
- package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
- package/dist_ts_authswitch/authority-spend.d.ts +103 -0
- package/dist_ts_authswitch/authority-spend.js +110 -0
- package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
- package/dist_ts_authswitch/authority-subscription.js +24 -1
- package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
- package/dist_ts_authswitch/authority-usagelimit.js +89 -39
- package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
- package/dist_ts_authswitch/authority-usagewindows.js +41 -16
- package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
- package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
- package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
- package/dist_ts_authswitch/classes.authoritycli.js +99 -30
- package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
- package/dist_ts_authswitch/classes.authorityclient.js +45 -15
- package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
- package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
- package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
- package/dist_ts_authswitch/classes.authorityimport.js +2 -2
- package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
- package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
- package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
- package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
- package/dist_ts_authswitch/classes.authorityusage.js +118 -4
- package/dist_ts_authswitch/classes.claudenative.d.ts +23 -8
- package/dist_ts_authswitch/classes.claudenative.js +49 -21
- package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
- package/dist_ts_authswitch/classes.claudesignin.js +72 -23
- package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
- package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
- package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
- package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
- package/dist_ts_authswitch/testsupport.d.ts +24 -5
- package/dist_ts_authswitch/testsupport.js +56 -12
- package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
- package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
- package/dist_ts_authswitch/ts_migration/index.js +3 -1
- package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
- package/dist_ts_interfaces/functions.authorityscope.js +35 -8
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/plugins.d.ts +2 -1
- package/dist_ts_interfaces/plugins.js +7 -4
- package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
- package/dist_ts_interfaces/requests.accounts.js +11 -1
- package/dist_ts_interfaces/requests.d.ts +2 -2
- package/dist_ts_interfaces/requests.js +1 -1
- package/docs/authswitch.md +75 -12
- package/package.json +5 -5
- package/readme.md +199 -22
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +2 -2
- package/ts/classes.accountsactions.ts +75 -1
- package/ts/classes.controller.ts +4 -4
- package/ts/classes.flexaccountaccess.ts +2 -1
- package/ts/classes.harnessreleases.ts +25 -4
- package/ts/classes.inferencegateway.ts +184 -64
- package/ts/classes.inferenceplacement.ts +6 -4
- package/ts/classes.inferencerouter.ts +1026 -314
- package/ts/classes.inferenceservice.ts +56 -13
- package/ts/classes.inferencespend.ts +870 -0
- package/ts/classes.inferenceusagecache.ts +16 -4
- package/ts/classes.inferenceusagetap.ts +159 -34
- package/ts/classes.proxycontrol.ts +3 -3
- package/ts/classes.proxydaemon.ts +2 -1
- package/ts/classes.proxystore.ts +11 -1
- package/ts/fleet/classes.claudeworkers.ts +9 -8
- package/ts/functions.claudecoderelease.ts +13 -6
- package/ts/functions.inferencecli.ts +11 -7
- package/ts/interfaces.auth.ts +2 -0
- package/ts/plugins.ts +7 -0
- package/ts_authswitch/authority-apikeys.ts +85 -0
- package/ts_authswitch/authority-contract.ts +201 -34
- package/ts_authswitch/authority-overview.ts +7 -0
- package/ts_authswitch/authority-placement.ts +12 -4
- package/ts_authswitch/authority-runtime-contract.ts +2 -2
- package/ts_authswitch/authority-spend.ts +168 -0
- package/ts_authswitch/authority-subscription.ts +28 -1
- package/ts_authswitch/authority-usagelimit.ts +95 -40
- package/ts_authswitch/authority-usagewindows.ts +42 -17
- package/ts_authswitch/classes.authoritybroker.ts +161 -68
- package/ts_authswitch/classes.authoritycli.ts +112 -32
- package/ts_authswitch/classes.authorityclient.ts +66 -19
- package/ts_authswitch/classes.authoritydaemon.ts +43 -13
- package/ts_authswitch/classes.authoritydatabase.ts +178 -8
- package/ts_authswitch/classes.authorityimport.ts +1 -1
- package/ts_authswitch/classes.authoritymodels.ts +165 -19
- package/ts_authswitch/classes.authorityregistry.ts +3 -0
- package/ts_authswitch/classes.authorityusage.ts +171 -10
- package/ts_authswitch/classes.claudenative.ts +51 -22
- package/ts_authswitch/classes.claudesignin.ts +74 -24
- package/ts_authswitch/classes.codexmanaged.ts +30 -16
- package/ts_authswitch/classes.harnessprocesses.ts +39 -8
- package/ts_authswitch/native-contracts.md +82 -21
- package/ts_authswitch/readme.md +1 -1
- package/ts_authswitch/testsupport.ts +58 -11
- package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
- package/ts_authswitch/ts_migration/index.ts +2 -0
- package/ts_interfaces/functions.authorityscope.ts +43 -8
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/plugins.ts +6 -3
- package/ts_interfaces/requests.accounts.ts +48 -0
- package/ts_interfaces/requests.ts +4 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/accounts.plugins.ts +7 -1
- package/ts_web/accounts.templates.ts +20 -11
- package/ts_web/classes.accountsreadmodel.ts +326 -71
- package/ts_web/elements.accounts.ts +219 -20
- package/ts_web/elements.codexconnections.ts +3 -1
- package/ts_web/plugins.ts +4 -2
|
@@ -8,6 +8,9 @@ import { authSwitchCombineUsageObservations, authSwitchObservedUsage,
|
|
|
8
8
|
type TAuthSwitchUsageObservation } from './authority-usageheaders.js';
|
|
9
9
|
import { usageWindowPeriod } from './accounts.js';
|
|
10
10
|
import type { IAuthSwitchTokenUsage } from './authority-usagetokens.js';
|
|
11
|
+
import { authSwitchSpendPeriod, type IAuthSwitchSpendReading } from './authority-spend.js';
|
|
12
|
+
import type { IAuthSwitchSpendAnswer, TAuthSwitchPaymentProblemCause as TCause } from './authority-contract.js';
|
|
13
|
+
import { authSwitchPaymentProblemHeld } from './authority-subscription.js';
|
|
11
14
|
import { AuthSwitchUsageEstimator, authSwitchRolledOpenAiLimits, type IAuthSwitchUsageEstimateState,
|
|
12
15
|
type IAuthSwitchUsageEstimatorOptions, type TAuthSwitchEstimateBasis } from './classes.authorityusageestimate.js';
|
|
13
16
|
|
|
@@ -16,12 +19,12 @@ type TRateLimits = IOpenAiAccountRateLimits;
|
|
|
16
19
|
/**
|
|
17
20
|
* Why a usage reading is missing or incomplete. `held_in_container`: the Claude login runs in a registered container
|
|
18
21
|
* home, whose files are the container's; the authority reads its usage again once the login is moved out. `pending`:
|
|
19
|
-
* the account's first read has not finished yet.
|
|
22
|
+
* the account's first read has not finished yet. `spend_unavailable`: an API key's spend ledger could not be read.
|
|
20
23
|
*/
|
|
21
24
|
export type TAuthSwitchUsageProblem =
|
|
22
25
|
| 'none' | 'unsupported' | 'access_unavailable' | 'provider_unavailable'
|
|
23
26
|
| 'provider_rejected' | 'profile_unavailable' | 'usage_unavailable'
|
|
24
|
-
| 'context_changed' | 'expired' | 'held_in_container' | 'pending';
|
|
27
|
+
| 'context_changed' | 'expired' | 'held_in_container' | 'pending' | 'spend_unavailable';
|
|
25
28
|
|
|
26
29
|
export interface IAuthSwitchNativeUsageSource {
|
|
27
30
|
homeId: string;
|
|
@@ -69,10 +72,18 @@ export interface IAuthSwitchClaudeStatus {
|
|
|
69
72
|
rateLimit: { retryAt: string | null } | null;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
/** A usage reading the authority reads from a provider for a subscription sign-in, and stores between its runs. */
|
|
76
|
+
export type TAuthSwitchProviderUsageReading =
|
|
73
77
|
| { kind: 'openai'; rateLimits: TRateLimits }
|
|
74
78
|
| { kind: 'anthropic'; status: IAuthSwitchClaudeStatus };
|
|
75
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A login's usage reading: a subscription sign-in's, as its provider stated it, or an API key's monthly spend
|
|
82
|
+
* (`spend`), which the authority answers from what AGL's inference proxy reported and never reads from a provider.
|
|
83
|
+
*/
|
|
84
|
+
export type TAuthSwitchUsageReading = TAuthSwitchProviderUsageReading
|
|
85
|
+
| { kind: 'spend'; spend: IAuthSwitchSpendReading };
|
|
86
|
+
|
|
76
87
|
export interface IAuthSwitchUsageAccess {
|
|
77
88
|
accessToken: string;
|
|
78
89
|
accountId: string;
|
|
@@ -96,10 +107,33 @@ export interface IAuthSwitchUsageAccess {
|
|
|
96
107
|
export interface IAuthSwitchPaymentProblem {
|
|
97
108
|
at: string;
|
|
98
109
|
subscription: IClaudeSubscription | null;
|
|
110
|
+
/**
|
|
111
|
+
* What the provider refused for (`TAuthSwitchPaymentProblemCause`), present only for `spend_cap` and `quota`, which
|
|
112
|
+
* only an API key's login is held with; absent, the cause is `billing`, on a Claude login and an API key alike. A
|
|
113
|
+
* problem with a cause has no subscription.
|
|
114
|
+
*/
|
|
115
|
+
cause?: Exclude<TAuthSwitchPaymentProblemCause, 'billing'>;
|
|
116
|
+
/**
|
|
117
|
+
* When a `spend_cap` lifts by itself: the end of the month in UTC `at` falls in (`authSwitchSpendPeriod(at).end`),
|
|
118
|
+
* present exactly for that cause; once it passed the problem holds nothing (`authSwitchPaymentProblemHeld`). Every
|
|
119
|
+
* other problem holds until it is cleared: a Claude login's by a read, an API key's by a re-check (`usage.recheck`).
|
|
120
|
+
*/
|
|
121
|
+
until?: string;
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
/**
|
|
102
|
-
*
|
|
125
|
+
* Why a provider refused inference on an API key for money, as AGL's inference proxy read the refusal: `billing`, the
|
|
126
|
+
* organization's billing (Anthropic HTTP 402 `billing_error`, and every Claude sign-in's payment problem); `spend_cap`,
|
|
127
|
+
* the monthly spend cap of the key's usage tier, which lifts at 00:00 UTC on the first of the next month (Anthropic 429
|
|
128
|
+
* `enforced_spend_limit_reached`); `quota`, the organization's or project's credit or spend limit (OpenAI 429
|
|
129
|
+
* `insufficient_quota`).
|
|
130
|
+
*/
|
|
131
|
+
export type TAuthSwitchPaymentProblemCause = 'billing' | 'spend_cap' | 'quota';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How a re-check went (`AuthSwitchAuthorityUsage.recheck`). `cleared`: the login is an API key, whose hold a re-check
|
|
135
|
+
* lifts without asking the provider, which states no billing or quota on a model list; the next request shows whether the
|
|
136
|
+
* provider takes the key again. Every other outcome is a Claude login's read: `healthy` -- the profile was found, the sign-in
|
|
103
137
|
* accepted and the identity matched -- clears a payment problem; every other outcome clears nothing. `credential_rejected`:
|
|
104
138
|
* Anthropic rejected the sign-in, renewed too. `token_unusable`: no access token usable for a profile read (it could not
|
|
105
139
|
* be resolved, is not fresh enough, or lacks the `user:profile` scope). `profile_missing`: the profile read failed or its
|
|
@@ -108,7 +142,7 @@ export interface IAuthSwitchPaymentProblem {
|
|
|
108
142
|
* lies ahead.
|
|
109
143
|
*/
|
|
110
144
|
export type TAuthSwitchRecheckOutcome = 'healthy' | 'credential_rejected' | 'token_unusable' | 'profile_missing'
|
|
111
|
-
| 'mismatch' | 'read_failed';
|
|
145
|
+
| 'mismatch' | 'read_failed' | 'cleared';
|
|
112
146
|
|
|
113
147
|
/** A re-check's answer: the usage the authority holds once its read finished, and how that read went. */
|
|
114
148
|
export interface IAuthSwitchUsageRecheck {
|
|
@@ -120,7 +154,7 @@ export interface IAuthSwitchUsageRecheck {
|
|
|
120
154
|
export interface IAuthSwitchStoredUsage {
|
|
121
155
|
context: IAuthSwitchUsageContext;
|
|
122
156
|
updateId: string;
|
|
123
|
-
reading:
|
|
157
|
+
reading: TAuthSwitchProviderUsageReading | null;
|
|
124
158
|
observedAt: string | null;
|
|
125
159
|
currentUntil: string | null;
|
|
126
160
|
staleUntil: string | null;
|
|
@@ -262,6 +296,15 @@ export interface IAuthSwitchAuthorityUsageOptions {
|
|
|
262
296
|
estimate?: IAuthSwitchUsageEstimatorOptions;
|
|
263
297
|
/** Where the reader's notes go, one line each: standard error, the service's journal, unless set. */
|
|
264
298
|
log?: (lineArg: string) => void;
|
|
299
|
+
/**
|
|
300
|
+
* An API key's spend ledger: its month's reading at `nowArg`, and a spend report recorded in one transaction
|
|
301
|
+
* (`AuthSwitchAuthorityDatabase.readSpend`, `.recordSpend`). Without it the authority answers no key's usage.
|
|
302
|
+
*/
|
|
303
|
+
spend?: {
|
|
304
|
+
read(accountId: string, loginId: string, nowArg: number): Promise<IAuthSwitchSpendReading>;
|
|
305
|
+
record(accountId: string, loginId: string, answers: readonly IAuthSwitchSpendAnswer[],
|
|
306
|
+
nowArg: number): Promise<IAuthSwitchSpendReading>;
|
|
307
|
+
};
|
|
265
308
|
}
|
|
266
309
|
|
|
267
310
|
/** What a client that served requests on a login reports about the account: see `report`. */
|
|
@@ -345,6 +388,15 @@ const usable = (context: IAuthSwitchUsageContext): boolean =>
|
|
|
345
388
|
|| (context.owner === 'claude_native' && context.nativeSource !== null
|
|
346
389
|
&& context.grantState === 'native'))));
|
|
347
390
|
|
|
391
|
+
/** An API key of its account's provider, held or removed: its spend is recorded either way. */
|
|
392
|
+
const apiKeyOfAccount = (context: IAuthSwitchUsageContext): boolean => context.workspaceId === 'api-key'
|
|
393
|
+
&& ((context.providerId === 'anthropic' && context.purpose === 'anthropic_api_key')
|
|
394
|
+
|| (context.providerId === 'openai' && context.purpose === 'openai_api_key'));
|
|
395
|
+
|
|
396
|
+
/** An API key's login the authority holds: a key nothing else renews. Its usage is its spend ledger. */
|
|
397
|
+
const apiKeyLogin = (context: IAuthSwitchUsageContext): boolean => apiKeyOfAccount(context) && context.owner === 'daemon'
|
|
398
|
+
&& context.nativeSource === null;
|
|
399
|
+
|
|
348
400
|
/** A login read through Claude Code's own home: its context names the home only once that home is opened for a read. */
|
|
349
401
|
const native = (context: IAuthSwitchUsageContext): boolean => context.providerId === 'anthropic'
|
|
350
402
|
&& context.purpose === 'claude_host_native' && context.owner === 'claude_native' && context.grantState === 'native';
|
|
@@ -664,7 +716,8 @@ export class AuthSwitchAuthorityUsage {
|
|
|
664
716
|
* carries it. Refused for any login but a Claude login the authority renews, and for one with no usage record of its
|
|
665
717
|
* sign-in yet, which the proxy never routed to. The provider is not asked.
|
|
666
718
|
*/
|
|
667
|
-
public reportPaymentProblem(accountId: string, loginId: string, observedAt: string
|
|
719
|
+
public reportPaymentProblem(accountId: string, loginId: string, observedAt: string,
|
|
720
|
+
cause: TCause = 'billing'): Promise<IAuthSwitchUsageSnapshot> {
|
|
668
721
|
if (this.closed) return Promise.reject(new Error('Authority usage helper is closed.'));
|
|
669
722
|
return this.admit((async () => {
|
|
670
723
|
if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(loginId) || !Number.isFinite(Date.parse(observedAt))) {
|
|
@@ -672,9 +725,21 @@ export class AuthSwitchAuthorityUsage {
|
|
|
672
725
|
}
|
|
673
726
|
for (let attempt = 1; ; attempt++) {
|
|
674
727
|
const context = await this.currentContext(accountId, loginId);
|
|
728
|
+
if (context && context.accountId === accountId && context.loginId === loginId && !context.removed
|
|
729
|
+
&& apiKeyLogin(context) && context.grantState === 'ready') {
|
|
730
|
+
if (await this.holdApiKey(context, observedAt, cause)) return this.spendAnswer(context);
|
|
731
|
+
if (attempt >= paymentProblemWriteAttempts) {
|
|
732
|
+
throw new AuthSwitchRefusal('account_changed',
|
|
733
|
+
'The key\'s record kept changing, so its payment problem was not written; report it again.');
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
675
737
|
if (!context || context.accountId !== accountId || context.loginId !== loginId || !paymentProblemLogin(context)) {
|
|
676
738
|
throw new AuthSwitchRefusal('login_unavailable',
|
|
677
|
-
'Only a Claude login the account service renews is held with a payment problem.');
|
|
739
|
+
'Only a Claude login the account service renews, or an API key it holds, is held with a payment problem.');
|
|
740
|
+
}
|
|
741
|
+
if (cause !== 'billing') {
|
|
742
|
+
throw new AuthSwitchRefusal('login_unavailable', 'A Claude login is held only for a billing error.');
|
|
678
743
|
}
|
|
679
744
|
const stored = await this.options.store.read(accountId, loginId);
|
|
680
745
|
const held = stored && authSwitchSameUsageSource(context, stored.context) ? stored : null;
|
|
@@ -714,9 +779,13 @@ export class AuthSwitchAuthorityUsage {
|
|
|
714
779
|
throw new Error('Invalid authority usage identity.');
|
|
715
780
|
}
|
|
716
781
|
const context = await this.currentContext(accountId, loginId);
|
|
782
|
+
if (context && context.accountId === accountId && context.loginId === loginId && !context.removed
|
|
783
|
+
&& apiKeyLogin(context)) {
|
|
784
|
+
return this.clearApiKey(context);
|
|
785
|
+
}
|
|
717
786
|
if (!context || context.accountId !== accountId || context.loginId !== loginId
|
|
718
787
|
|| context.providerId !== 'anthropic' || context.purpose !== 'claude_host_native') {
|
|
719
|
-
throw new AuthSwitchRefusal('login_unavailable', 'Only a Claude login is re-checked.');
|
|
788
|
+
throw new AuthSwitchRefusal('login_unavailable', 'Only a Claude login or an API key is re-checked.');
|
|
720
789
|
}
|
|
721
790
|
const key = this.key(accountId, loginId);
|
|
722
791
|
await this.answer(accountId, loginId, null);
|
|
@@ -737,6 +806,97 @@ export class AuthSwitchAuthorityUsage {
|
|
|
737
806
|
})());
|
|
738
807
|
}
|
|
739
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Records what answers relayed on an API key's login cost (`authswitch.authority.usage.spend`) and answers the key's
|
|
811
|
+
* usage once the report committed. A removed key's spend is recorded too; its usage then answers unavailable.
|
|
812
|
+
*/
|
|
813
|
+
public recordSpend(accountId: string, loginId: string,
|
|
814
|
+
answers: readonly IAuthSwitchSpendAnswer[]): Promise<IAuthSwitchUsageSnapshot> {
|
|
815
|
+
if (this.closed) return Promise.reject(new Error('Authority usage helper is closed.'));
|
|
816
|
+
return this.admit((async () => {
|
|
817
|
+
if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(loginId)) {
|
|
818
|
+
throw new Error('Invalid authority usage identity.');
|
|
819
|
+
}
|
|
820
|
+
const context = await this.currentContext(accountId, loginId);
|
|
821
|
+
const spend = this.options.spend;
|
|
822
|
+
if (!spend || !context || context.accountId !== accountId || context.loginId !== loginId || !apiKeyOfAccount(context)) {
|
|
823
|
+
throw new AuthSwitchRefusal('login_unavailable', 'Only an API key\'s login records spend.');
|
|
824
|
+
}
|
|
825
|
+
await spend.record(accountId, loginId, answers, this.now());
|
|
826
|
+
return this.answer(accountId, loginId, null);
|
|
827
|
+
})());
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* An API key's usage: its month's spend from the ledger, current as of now, which stays current until the month ends
|
|
832
|
+
* or a report or a limit change moves it, and the payment problem it is held with, while it holds.
|
|
833
|
+
*/
|
|
834
|
+
private async spendAnswer(context: IAuthSwitchUsageContext): Promise<IAuthSwitchUsageSnapshot> {
|
|
835
|
+
const { accountId, loginId } = context;
|
|
836
|
+
if (context.removed || !this.options.spend) {
|
|
837
|
+
return this.unavailable(accountId, loginId, context.removed ? 'access_unavailable' : 'unsupported');
|
|
838
|
+
}
|
|
839
|
+
const now = this.now();
|
|
840
|
+
let spend: IAuthSwitchSpendReading;
|
|
841
|
+
let held: IAuthSwitchStoredUsage | null;
|
|
842
|
+
try {
|
|
843
|
+
[spend, held] = await Promise.all([this.options.spend.read(accountId, loginId, now),
|
|
844
|
+
this.options.store.read(accountId, loginId)]);
|
|
845
|
+
} catch {
|
|
846
|
+
this.log('Authswitch could not read an API key\'s spend; the key is answered without a reading.');
|
|
847
|
+
return this.unavailable(accountId, loginId, 'spend_unavailable');
|
|
848
|
+
}
|
|
849
|
+
const paymentProblem = held && authSwitchSameUsageSource(context, held.context) && held.paymentProblem
|
|
850
|
+
&& authSwitchPaymentProblemHeld(held.paymentProblem, now) ? held.paymentProblem : undefined;
|
|
851
|
+
const readAt = timestamp(now);
|
|
852
|
+
return { accountId, loginId, state: 'current', reading: { kind: 'spend', spend }, observedAt: readAt, readAt,
|
|
853
|
+
currentUntil: spend.resetAt, staleUntil: spend.resetAt, lastAttemptAt: null, nextAttemptAt: null,
|
|
854
|
+
lastReadError: null, problem: 'none', ...(paymentProblem ? { paymentProblem } : {}) };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Holds an API key with a payment problem on its usage record, which holds nothing else: when it was seen -- a time
|
|
859
|
+
* ahead of the authority's clock counts as now -- and its cause; `billing` is written as no cause, and a `spend_cap`
|
|
860
|
+
* lifts at the end of the month it was seen in. False when another write of the record landed first.
|
|
861
|
+
*/
|
|
862
|
+
private async holdApiKey(context: IAuthSwitchUsageContext, observedAt: string, cause: TCause): Promise<boolean> {
|
|
863
|
+
const stored = await this.options.store.read(context.accountId, context.loginId);
|
|
864
|
+
const held = stored && authSwitchSameUsageSource(context, stored.context) ? stored : null;
|
|
865
|
+
const now = this.now();
|
|
866
|
+
const at = timestamp(Math.min(Date.parse(observedAt), now));
|
|
867
|
+
const paymentProblem: IAuthSwitchPaymentProblem = cause === 'spend_cap'
|
|
868
|
+
? { at, subscription: null, cause, until: authSwitchSpendPeriod(Date.parse(at)).end }
|
|
869
|
+
: cause === 'quota' ? { at, subscription: null, cause } : { at, subscription: null };
|
|
870
|
+
const next: IAuthSwitchStoredUsage = { context, updateId: plugins.crypto.randomUUID(), reading: null,
|
|
871
|
+
observedAt: null, currentUntil: null, staleUntil: null, lastAttemptAt: at, nextAttemptAt: at, failures: 0,
|
|
872
|
+
problem: 'none', paymentProblem };
|
|
873
|
+
return this.options.store.publishIfCurrent(context, held && sameContext(context, held.context) ? held.updateId : null,
|
|
874
|
+
next);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* A re-check of an API key: lifts the payment problem it is held with, without asking the provider, whose model list
|
|
879
|
+
* states no billing or quota; the next request shows whether the provider takes the key again.
|
|
880
|
+
*/
|
|
881
|
+
private async clearApiKey(context: IAuthSwitchUsageContext): Promise<IAuthSwitchUsageRecheck> {
|
|
882
|
+
for (let attempt = 1; ; attempt++) {
|
|
883
|
+
const stored = await this.options.store.read(context.accountId, context.loginId);
|
|
884
|
+
const held = stored && authSwitchSameUsageSource(context, stored.context) && stored.paymentProblem ? stored : null;
|
|
885
|
+
if (!held) return { usage: await this.spendAnswer(context), outcome: 'cleared' };
|
|
886
|
+
// A record without the member: the store's transition drops a problem the draft does not hold.
|
|
887
|
+
const { paymentProblem: _lifted, ...rest } = held;
|
|
888
|
+
const next: IAuthSwitchStoredUsage = { ...rest, context, updateId: plugins.crypto.randomUUID() };
|
|
889
|
+
if (await this.options.store.publishIfCurrent(context, sameContext(context, held.context) ? held.updateId : null,
|
|
890
|
+
next)) {
|
|
891
|
+
return { usage: await this.spendAnswer(context), outcome: 'cleared' };
|
|
892
|
+
}
|
|
893
|
+
if (attempt >= paymentProblemWriteAttempts) {
|
|
894
|
+
throw new AuthSwitchRefusal('account_changed', 'The key\'s record kept changing, so its hold was not lifted; '
|
|
895
|
+
+ 're-check it again.');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
740
900
|
/** Logs a Claude subscription status the first time this run reads it, up to `maximumLoggedSubscriptionStatuses`. */
|
|
741
901
|
private noteSubscriptionStatus(statusArg: string | null): void {
|
|
742
902
|
if (statusArg === null || this.subscriptionStatusesCapped || this.loggedSubscriptionStatuses.has(statusArg)) return;
|
|
@@ -774,6 +934,7 @@ export class AuthSwitchAuthorityUsage {
|
|
|
774
934
|
if (context.accountId !== accountId || context.loginId !== loginId) {
|
|
775
935
|
return this.unavailable(accountId, loginId, 'context_changed');
|
|
776
936
|
}
|
|
937
|
+
if (apiKeyLogin(context)) return this.spendAnswer(context);
|
|
777
938
|
if (!readable(context)) {
|
|
778
939
|
this.unschedule(accountId, loginId);
|
|
779
940
|
return this.unavailable(accountId, loginId,
|
|
@@ -999,7 +1160,7 @@ export class AuthSwitchAuthorityUsage {
|
|
|
999
1160
|
previous = latest && sameContext(context, latest.context) ? latest : null;
|
|
1000
1161
|
held = latest && authSwitchSameUsageSource(context, latest.context) ? latest : null;
|
|
1001
1162
|
};
|
|
1002
|
-
let reading:
|
|
1163
|
+
let reading: TAuthSwitchProviderUsageReading | null = null;
|
|
1003
1164
|
/** Whether the read stated the account's windows: only then is it a reading the schedule waits a full slot after. */
|
|
1004
1165
|
let windowsRead = false;
|
|
1005
1166
|
let rateLimited = false;
|
|
@@ -14,11 +14,20 @@ export const CLAUDE_NATIVE_MINIMUM_VERSION = '2.1.273';
|
|
|
14
14
|
/**
|
|
15
15
|
* The releases whose lock table, live credential pickup and native-login selector were read from their own bundle
|
|
16
16
|
* (native-contracts.md). The usage of an adopted home's login is read from the home only while every Claude Code
|
|
17
|
-
* session running there runs one of these or a
|
|
18
|
-
* (`claudeNativeReleaseReadsAsVerified`, owner ruling 2026-10-22, AGL-685); an installed release
|
|
19
|
-
*
|
|
17
|
+
* session running there runs one of these or a release from `CLAUDE_NATIVE_READ_AS_VERIFIED_FROM` on
|
|
18
|
+
* (`claudeNativeReleaseReadsAsVerified`, owner ruling 2026-10-22, AGL-685); an installed release below that line that
|
|
19
|
+
* is not listed is read from only while no session runs in that home. The controller's verified release (2.1.306,
|
|
20
|
+
* AGL-686) and its fleet floor (2.1.282) are listed. 2.1.288, the release AGL's own Claude chats run through the Agent
|
|
21
|
+
* SDK, had these items read from the SDK's binary (AGL-688) and is listed for the usage read only: the fleet never ran
|
|
22
|
+
* it, so it is never the fleet's floor or the verified release.
|
|
20
23
|
*/
|
|
21
|
-
export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282'];
|
|
24
|
+
export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282', '2.1.288', '2.1.306'];
|
|
25
|
+
/**
|
|
26
|
+
* The line from which the usage read takes any release, listed or not, as verified (owner ruling 2026-10-22, AGL-685):
|
|
27
|
+
* the controller's fleet floor (`claudeCodeFleetFloor`), which a test holds equal to it. It does not move when a newer
|
|
28
|
+
* release is verified.
|
|
29
|
+
*/
|
|
30
|
+
export const CLAUDE_NATIVE_READ_AS_VERIFIED_FROM = '2.1.282';
|
|
22
31
|
/**
|
|
23
32
|
* The releases whose credential custody contract was read from their own bundle (native-contracts.md): the OAuth
|
|
24
33
|
* refresh lock and its legacy twin, the credential store's write lock and the global config lock; the store read
|
|
@@ -126,7 +135,8 @@ export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
|
|
|
126
135
|
public readonly sessions: readonly IClaudeNativeRefusedSession[] = []) {
|
|
127
136
|
super((reason === 'unsupported_release'
|
|
128
137
|
? `Reading Claude Code's own login requires Claude Code ${CLAUDE_NATIVE_MINIMUM_VERSION} or newer, and a session `
|
|
129
|
-
+ `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}) or
|
|
138
|
+
+ `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}) or any release `
|
|
139
|
+
+ `from ${CLAUDE_NATIVE_READ_AS_VERIFIED_FROM} on.`
|
|
130
140
|
: `Cannot prove that Claude Code uses its native claude.ai login (${reason}).`)
|
|
131
141
|
+ (sessions.length ? ` Refused: ${sessions.map(describeClaudeNativeRefusedSession).join('; ')}.` : ''));
|
|
132
142
|
}
|
|
@@ -179,6 +189,10 @@ export interface IClaudeNativeRunningContext {
|
|
|
179
189
|
/** The Agent SDK version whose platform package holds its executable, for an Agent SDK binary only. */
|
|
180
190
|
agentSdkVersion?: string;
|
|
181
191
|
env: NodeJS.ProcessEnv | null;
|
|
192
|
+
/**
|
|
193
|
+
* Its working directory as its own root sees it; null when that cannot be resolved -- deleted under it, for one -- so
|
|
194
|
+
* nothing that needs it (a relative home or store, its project settings) can be proven.
|
|
195
|
+
*/
|
|
182
196
|
cwd: string | null;
|
|
183
197
|
hasSettingsOverride: boolean | null;
|
|
184
198
|
/** The selected native home as seen through this process's mount namespace. */
|
|
@@ -251,17 +265,15 @@ const atLeast = (version: [number, number, number], minimum: [number, number, nu
|
|
|
251
265
|
const claudeBuildPattern = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9a-f]{1,64})?$/;
|
|
252
266
|
/**
|
|
253
267
|
* Whether a session of this build is read like a verified release when its home's login is read for usage: a listed
|
|
254
|
-
* release, or a build of a release
|
|
255
|
-
*
|
|
268
|
+
* release, or a build of a release from `CLAUDE_NATIVE_READ_AS_VERIFIED_FROM` on. A hashed build is ordered by its
|
|
269
|
+
* release. Custody never uses this: it matches whole build names (`CLAUDE_NATIVE_CUSTODY_RELEASES`).
|
|
256
270
|
*/
|
|
257
271
|
export const claudeNativeReleaseReadsAsVerified = (build: string | null): boolean => {
|
|
258
272
|
if (build === null) return false;
|
|
259
273
|
if (CLAUDE_NATIVE_VERIFIED_RELEASES.includes(build)) return true;
|
|
260
274
|
const match = claudeBuildPattern.exec(build);
|
|
261
275
|
if (!match) return false;
|
|
262
|
-
|
|
263
|
-
.reduce((left, right) => atLeast(left, right) ? left : right);
|
|
264
|
-
return atLeast([Number(match[1]), Number(match[2]), Number(match[3])], newest);
|
|
276
|
+
return atLeast([Number(match[1]), Number(match[2]), Number(match[3])], parseClaudeVersion(CLAUDE_NATIVE_READ_AS_VERIFIED_FROM)!);
|
|
265
277
|
};
|
|
266
278
|
|
|
267
279
|
/**
|
|
@@ -458,10 +470,15 @@ export const claudeNativeCredentialStoreDirectory = (env: NodeJS.ProcessEnv): st
|
|
|
458
470
|
? plugins.path.join(env.HOME, '.claude').normalize('NFC') : null;
|
|
459
471
|
};
|
|
460
472
|
|
|
473
|
+
/**
|
|
474
|
+
* An environment selects this home and keeps its credential store there too: a store elsewhere
|
|
475
|
+
* (`CLAUDE_SECURESTORAGE_CONFIG_DIR`) selects another login, so it is refused as an override.
|
|
476
|
+
*/
|
|
461
477
|
const assertNativeHome = (env: NodeJS.ProcessEnv, configDir: string): void => {
|
|
462
|
-
const selected
|
|
463
|
-
|
|
464
|
-
|
|
478
|
+
for (const selected of [selectedNativeHome(env), claudeNativeCredentialStoreDirectory(env)]) {
|
|
479
|
+
if (!selected || !plugins.path.isAbsolute(selected) || plugins.path.normalize(selected) !== configDir) {
|
|
480
|
+
throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
|
|
481
|
+
}
|
|
465
482
|
}
|
|
466
483
|
};
|
|
467
484
|
|
|
@@ -695,10 +712,24 @@ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
|
|
|
695
712
|
}
|
|
696
713
|
}
|
|
697
714
|
|
|
715
|
+
/**
|
|
716
|
+
* The working directory `/proc/<pid>/cwd` names, when that path is the very directory the process works in as its own
|
|
717
|
+
* root sees it; null otherwise, as for a directory deleted under the process, which the link names `<path> (deleted)`.
|
|
718
|
+
*/
|
|
719
|
+
const resolvedProcessCwd = (pid: number, root: string): string | null => {
|
|
720
|
+
try {
|
|
721
|
+
const cwd = plugins.fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
722
|
+
return plugins.path.isAbsolute(cwd) && sameDirectory(directoryIdentity(`/proc/${pid}/cwd`),
|
|
723
|
+
directoryIdentity(processViewPath(cwd, root))) ? cwd : null;
|
|
724
|
+
} catch { return null; }
|
|
725
|
+
};
|
|
726
|
+
|
|
698
727
|
/**
|
|
699
728
|
* Every Claude Code process of this user, the home it selects and the directory of its credential store, resolved
|
|
700
729
|
* through its own mount namespace (`/proc/<pid>/root`), so a process in a container that bind-mounts a home is matched to
|
|
701
|
-
* that home's inode. A live process whose context cannot be read is listed with unknown fields rather than skipped.
|
|
730
|
+
* that home's inode. A live process whose context cannot be read is listed with unknown fields rather than skipped. A
|
|
731
|
+
* working directory that cannot be resolved leaves `cwd` unknown and only what depends on it: a home or store the
|
|
732
|
+
* process names by an absolute path is still resolved under its root, one named by a relative path is not.
|
|
702
733
|
*/
|
|
703
734
|
export const scanClaudeRunningContexts = (): readonly IClaudeNativeRunningContext[] => {
|
|
704
735
|
const contexts: IClaudeNativeRunningContext[] = [];
|
|
@@ -725,20 +756,18 @@ export const scanClaudeRunningContexts = (): readonly IClaudeNativeRunningContex
|
|
|
725
756
|
}
|
|
726
757
|
mountNamespace = plugins.fs.readlinkSync(`/proc/${running.pid}/ns/mnt`);
|
|
727
758
|
env = selectorEnvironment(readProcessContext(`/proc/${running.pid}/environ`));
|
|
728
|
-
cwd = plugins.fs.readlinkSync(`/proc/${running.pid}/cwd`);
|
|
729
759
|
const root = `/proc/${running.pid}/root`;
|
|
730
760
|
processRoot = root;
|
|
731
|
-
|
|
732
|
-
directoryIdentity(`/proc/${running.pid}/cwd`),
|
|
733
|
-
directoryIdentity(processViewPath(cwd, root)))) {
|
|
734
|
-
throw new Error('Claude process working directory cannot be resolved in its mount namespace.');
|
|
735
|
-
}
|
|
761
|
+
cwd = resolvedProcessCwd(running.pid, root);
|
|
736
762
|
// A directory the process names, as it sees it: an absolute path under its own root, a relative one under its cwd.
|
|
737
763
|
const processDirectory = (selected: string | null, name: string): IClaudeNativeDirectoryIdentity => {
|
|
738
764
|
if (!selected) throw new Error(`Claude process ${name} is unknown.`);
|
|
739
765
|
if (plugins.path.isAbsolute(selected) && plugins.path.normalize(selected) !== selected) {
|
|
740
766
|
throw new Error(`Claude process ${name} path is ambiguous.`);
|
|
741
767
|
}
|
|
768
|
+
if (!plugins.path.isAbsolute(selected) && cwd === null) {
|
|
769
|
+
throw new Error(`Claude process ${name} is relative to a working directory that cannot be resolved.`);
|
|
770
|
+
}
|
|
742
771
|
return directoryIdentity(plugins.path.isAbsolute(selected)
|
|
743
772
|
? processViewPath(selected, root)
|
|
744
773
|
// Preserve the proc magic symlink before `..` is resolved by the target's VFS.
|
|
@@ -908,8 +937,8 @@ export class ClaudeNativeAdapter {
|
|
|
908
937
|
|
|
909
938
|
/**
|
|
910
939
|
* The whole support check of reading the login Claude Code selects in the home: the home's custody, and the proof
|
|
911
|
-
* that Claude Code selects its native login there -- for the daemon's own environment
|
|
912
|
-
* process running on the home.
|
|
940
|
+
* that Claude Code selects its native login there -- for the daemon's own environment (its home and its credential
|
|
941
|
+
* store are both this home) and settings, and for every process running on the home.
|
|
913
942
|
*/
|
|
914
943
|
private assertSupported(): IClaudeNativeSupport {
|
|
915
944
|
this.assertHomeDirectories();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { AuthSwitchRefusal, authSwitchClaudeSignInArguments,
|
|
3
|
-
type IAuthSwitchClaudeSignInOperation } from './authority-contract.js';
|
|
2
|
+
import { AuthSwitchRefusal, authSwitchClaudeSignInArguments, authSwitchClaudeSignInHoldersText,
|
|
3
|
+
type IAuthSwitchClaudeSignInHolder, type IAuthSwitchClaudeSignInOperation } from './authority-contract.js';
|
|
4
4
|
import type { AuthSwitchGrantImport, IAuthSwitchImportClaudeTokens } from './classes.authorityimport.js';
|
|
5
5
|
import type { IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
6
6
|
import { canonicalContainerDirectory, ClaudeContainerHome, discardContainerPair, inspectClaudeContainerDirectory,
|
|
@@ -38,6 +38,20 @@ const isUuid = (value: string): boolean => /^[a-f0-9-]{36}$/.test(value);
|
|
|
38
38
|
const shellWord = (value: string): string => `'${value.replaceAll('\'', '\'\\\'\'')}'`;
|
|
39
39
|
const finished = (state: IAuthSwitchClaudeSignInOperation['state']): boolean =>
|
|
40
40
|
state !== 'pending' && state !== 'committing';
|
|
41
|
+
/**
|
|
42
|
+
* How much earlier than a sign-in's home a process must have started, by the process table, to be proven older than
|
|
43
|
+
* it: `ps` names the start to the whole second, cut down, from a boot time the kernel also keeps to the whole second, so
|
|
44
|
+
* a process may have started up to two seconds after the moment the table names. The rest is room for clock slew.
|
|
45
|
+
*/
|
|
46
|
+
const startedBeforeMarginMs = 5_000;
|
|
47
|
+
const startedBefore = (startedAt: string | null, madeAtMs: number): boolean => {
|
|
48
|
+
const started = startedAt === null ? Number.NaN : Date.parse(startedAt);
|
|
49
|
+
return Number.isFinite(started) && started + startedBeforeMarginMs <= madeAtMs;
|
|
50
|
+
};
|
|
51
|
+
const sameHolders = (left: readonly IAuthSwitchClaudeSignInHolder[] | null,
|
|
52
|
+
right: readonly IAuthSwitchClaudeSignInHolder[] | null): boolean =>
|
|
53
|
+
left === null || right === null ? left === right : left.length === right.length && left.every((holder, index) =>
|
|
54
|
+
holder.pid === right[index]!.pid && holder.startedAt === right[index]!.startedAt && holder.reason === right[index]!.reason);
|
|
41
55
|
|
|
42
56
|
interface IClaudeSignIn {
|
|
43
57
|
operation: IAuthSwitchClaudeSignInOperation;
|
|
@@ -51,7 +65,8 @@ interface IClaudeSignIn {
|
|
|
51
65
|
|
|
52
66
|
/**
|
|
53
67
|
* Claude sign-ins: Claude Code signs in to a home the daemon made for that one sign-in, and the daemon imports the
|
|
54
|
-
* login it finds there once no Claude Code process
|
|
68
|
+
* login it finds there once no Claude Code process may still run on the home, then removes the home. Until then the
|
|
69
|
+
* sign-in names the processes that hold it (`heldBy`).
|
|
55
70
|
*
|
|
56
71
|
* The home is the daemon's own, below a directory of its user that no one else can write, and it is touched only by
|
|
57
72
|
* descriptor, as a container home is (`ClaudeContainerHome`): a home replaced by a link cannot redirect a read or the
|
|
@@ -103,12 +118,17 @@ export class ClaudeSignInAuthority {
|
|
|
103
118
|
const directory = plugins.path.join(this.options.directory, operationId);
|
|
104
119
|
const { parent, leaf } = inspectClaudeContainerDirectory(directory, this.owner);
|
|
105
120
|
if (leaf !== null) throw new Error('A Claude sign-in home already exists for this operation.');
|
|
106
|
-
|
|
121
|
+
// Read before the home is made, so no process that started after it can seem to have started before it.
|
|
107
122
|
const now = this.now();
|
|
123
|
+
ClaudeContainerHome.open(directory, parent, { create: true, owner: this.owner }).close();
|
|
108
124
|
const signIn: IClaudeSignIn = {
|
|
109
125
|
operation: { id: operationId, state: 'pending',
|
|
126
|
+
// The credential store is pinned to the home as well: one an inherited CLAUDE_SECURESTORAGE_CONFIG_DIR names
|
|
127
|
+
// would take the new login, over whatever login it holds, and the home would never get it.
|
|
110
128
|
prompt: { flow: 'terminal', directory,
|
|
111
|
-
command: `CLAUDE_CONFIG_DIR=${shellWord(directory)}
|
|
129
|
+
command: `CLAUDE_CONFIG_DIR=${shellWord(directory)} CLAUDE_SECURESTORAGE_CONFIG_DIR=${shellWord(directory)} `
|
|
130
|
+
+ `claude ${authSwitchClaudeSignInArguments.join(' ')}` },
|
|
131
|
+
heldBy: null,
|
|
112
132
|
result: null, error: null, createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
|
113
133
|
expiresAt: new Date(now + signInWindowMs).toISOString(), finishedAt: null, revision: 1 },
|
|
114
134
|
directory, parent, timer: null, commit: null,
|
|
@@ -189,14 +209,13 @@ export class ClaudeSignInAuthority {
|
|
|
189
209
|
}, this.pollIntervalMs);
|
|
190
210
|
}
|
|
191
211
|
|
|
192
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Looks once for the login: imports it when it is there and nothing holds it (`holders`), else waits on, keeping in
|
|
214
|
+
* `heldBy` what holds it. At `expiresAt` it ends instead and imports nothing, saying whether a login had arrived and
|
|
215
|
+
* what held it.
|
|
216
|
+
*/
|
|
193
217
|
private poll(signIn: IClaudeSignIn): void {
|
|
194
218
|
if (this.closed || signIn.operation.state !== 'pending') return;
|
|
195
|
-
if (this.now() >= Date.parse(signIn.operation.expiresAt)) {
|
|
196
|
-
this.end(signIn, 'failed', 'No Claude login arrived within fifteen minutes, so nothing was added. Start a new '
|
|
197
|
-
+ 'sign-in.');
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
219
|
let read: { pair: IClaudeContainerPair; identity: IClaudeContainerDirectoryIdentity | null } | null = null;
|
|
201
220
|
try {
|
|
202
221
|
const home = ClaudeContainerHome.open(signIn.directory, signIn.parent, { create: false, owner: this.owner });
|
|
@@ -210,29 +229,59 @@ export class ClaudeSignInAuthority {
|
|
|
210
229
|
return;
|
|
211
230
|
}
|
|
212
231
|
const { pair, identity } = read;
|
|
213
|
-
let
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
232
|
+
let holders: IAuthSwitchClaudeSignInHolder[] | null = null;
|
|
233
|
+
if (pair.credentialRaw !== null && identity !== null) {
|
|
234
|
+
try { holders = this.holders(identity, Date.parse(signIn.operation.createdAt)); }
|
|
235
|
+
catch {
|
|
236
|
+
discardContainerPair(pair);
|
|
237
|
+
this.end(signIn, 'failed', 'The authority could not tell whether Claude Code still runs on the sign-in home, '
|
|
238
|
+
+ 'so nothing was added. Start a new sign-in.');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (this.now() >= Date.parse(signIn.operation.expiresAt)) {
|
|
221
243
|
discardContainerPair(pair);
|
|
222
|
-
this.end(signIn, 'failed',
|
|
223
|
-
|
|
244
|
+
this.end(signIn, 'failed', holders === null
|
|
245
|
+
? 'No Claude login arrived within fifteen minutes, so nothing was added. Start a new sign-in.'
|
|
246
|
+
: holders.length > 0
|
|
247
|
+
? 'A Claude login arrived but was not added: until the sign-in ended, fifteen minutes after it started, these '
|
|
248
|
+
+ `Claude Code processes kept it from being imported: ${authSwitchClaudeSignInHoldersText(holders)}. Start a `
|
|
249
|
+
+ 'new sign-in once they have ended.'
|
|
250
|
+
: 'A Claude login arrived only as the sign-in ended, fifteen minutes after it started, so it was not added. '
|
|
251
|
+
+ 'Start a new sign-in.');
|
|
224
252
|
return;
|
|
225
253
|
}
|
|
226
|
-
if (
|
|
254
|
+
if (holders === null || holders.length > 0) {
|
|
227
255
|
discardContainerPair(pair);
|
|
256
|
+
if (!sameHolders(signIn.operation.heldBy, holders)) this.change(signIn, { heldBy: holders });
|
|
228
257
|
this.schedule(signIn);
|
|
229
258
|
return;
|
|
230
259
|
}
|
|
231
260
|
signIn.commit = this.commit(signIn, pair);
|
|
232
261
|
}
|
|
233
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The Claude Code processes that keep a login in the home `identity` from being imported, as the process table lists
|
|
265
|
+
* them: each whose home or credential store is the home, so it may still write the login there, and each whose home
|
|
266
|
+
* or store is unknown, unless it provably started before the home was made at `madeAtMs`. A process's settings are
|
|
267
|
+
* fixed when it starts, and the home's name was random until it was made, so an older process cannot name it.
|
|
268
|
+
*/
|
|
269
|
+
private holders(identity: IClaudeContainerDirectoryIdentity, madeAtMs: number): IAuthSwitchClaudeSignInHolder[] {
|
|
270
|
+
const holders: IAuthSwitchClaudeSignInHolder[] = [];
|
|
271
|
+
for (const context of this.runningContexts()) {
|
|
272
|
+
const reason: IAuthSwitchClaudeSignInHolder['reason'] | null =
|
|
273
|
+
(context.nativeHomeIdentity !== null && sameContainerDirectory(context.nativeHomeIdentity, identity))
|
|
274
|
+
|| (context.credentialStoreIdentity !== null && sameContainerDirectory(context.credentialStoreIdentity, identity))
|
|
275
|
+
? 'on_home'
|
|
276
|
+
: (context.nativeHomeIdentity === null || context.credentialStoreIdentity === null)
|
|
277
|
+
&& !startedBefore(context.startedAt, madeAtMs) ? 'uninspectable' : null;
|
|
278
|
+
if (reason !== null) holders.push({ pid: context.pid, startedAt: context.startedAt, reason });
|
|
279
|
+
}
|
|
280
|
+
return holders;
|
|
281
|
+
}
|
|
282
|
+
|
|
234
283
|
private async commit(signIn: IClaudeSignIn, pair: IClaudeContainerPair): Promise<void> {
|
|
235
|
-
this.change(signIn, { state: 'committing', prompt: null });
|
|
284
|
+
this.change(signIn, { state: 'committing', prompt: null, heldBy: null });
|
|
236
285
|
try {
|
|
237
286
|
const result = await this.grants.importClaudeSignIn({ directory: signIn.directory,
|
|
238
287
|
credentialRaw: pair.credentialRaw!, configRaw: pair.configRaw });
|
|
@@ -259,7 +308,8 @@ export class ClaudeSignInAuthority {
|
|
|
259
308
|
signIn.timer = null;
|
|
260
309
|
try { this.removeHome(signIn.parent, plugins.path.basename(signIn.directory)); }
|
|
261
310
|
catch { /* The sign-in is over whatever the removal did; the next start removes a home that stayed. */ }
|
|
262
|
-
this.change(signIn, { state, prompt: null, error, result,
|
|
311
|
+
this.change(signIn, { state, prompt: null, heldBy: null, error, result,
|
|
312
|
+
finishedAt: new Date(this.now()).toISOString() });
|
|
263
313
|
}
|
|
264
314
|
|
|
265
315
|
private change(signIn: IClaudeSignIn, patch: Partial<IAuthSwitchClaudeSignInOperation>): void {
|