@modelprofile.com/authswitch 6.5.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +300 -6
- package/dist_ts/authority-contract.js +1 -1
- package/dist_ts/authority-runtime-contract.d.ts +13 -0
- package/dist_ts/classes.accountlist.js +4 -3
- package/dist_ts/classes.authoritybroker.d.ts +25 -5
- package/dist_ts/classes.authoritybroker.js +388 -123
- package/dist_ts/classes.authoritycli.d.ts +24 -0
- package/dist_ts/classes.authoritycli.js +675 -0
- package/dist_ts/classes.authorityclient.d.ts +19 -7
- package/dist_ts/classes.authorityclient.js +51 -14
- package/dist_ts/classes.authoritydaemon.d.ts +28 -1
- package/dist_ts/classes.authoritydaemon.js +477 -36
- package/dist_ts/classes.authoritydatabase.d.ts +155 -11
- package/dist_ts/classes.authoritydatabase.js +1037 -69
- package/dist_ts/classes.authoritymodels.d.ts +229 -23
- package/dist_ts/classes.authoritymodels.js +867 -128
- package/dist_ts/classes.authoritypreuse.d.ts +47 -0
- package/dist_ts/classes.authoritypreuse.js +277 -0
- package/dist_ts/classes.authorityregistry.d.ts +9 -0
- package/dist_ts/classes.authorityregistry.js +32 -0
- package/dist_ts/classes.authoritysecrets.d.ts +1 -1
- package/dist_ts/classes.authoritysecrets.js +2 -2
- package/dist_ts/classes.authorityusage.d.ts +160 -0
- package/dist_ts/classes.authorityusage.js +429 -0
- package/dist_ts/classes.claudeauthority.d.ts +66 -0
- package/dist_ts/classes.claudeauthority.js +528 -0
- package/dist_ts/classes.claudecodeharness.js +3 -2
- package/dist_ts/classes.claudecodelocks.d.ts +13 -0
- package/dist_ts/classes.claudecodelocks.js +75 -3
- package/dist_ts/classes.claudenative.d.ts +130 -0
- package/dist_ts/classes.claudenative.js +708 -0
- package/dist_ts/classes.claudestatus.d.ts +9 -0
- package/dist_ts/classes.claudestatus.js +26 -9
- package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
- package/dist_ts/classes.claudetokenrefresh.js +74 -21
- package/dist_ts/classes.codexharness.js +4 -2
- package/dist_ts/classes.codexmanaged.d.ts +12 -1
- package/dist_ts/classes.codexmanaged.js +75 -14
- package/dist_ts/classes.codexpreuse.d.ts +8 -0
- package/dist_ts/classes.codexpreuse.js +24 -6
- package/dist_ts/classes.fileharness.d.ts +2 -0
- package/dist_ts/classes.fileharness.js +6 -4
- package/dist_ts/classes.limits.js +12 -7
- package/dist_ts/classes.opencodeharness.js +2 -2
- package/dist_ts/claudehttp.d.ts +10 -0
- package/dist_ts/claudehttp.js +29 -3
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/interfaces.harness.d.ts +6 -0
- package/dist_ts/interfaces.list.d.ts +9 -3
- package/dist_ts/plugins.d.ts +6 -3
- package/dist_ts/plugins.js +8 -4
- package/dist_ts/ts_migration/0001_authority_meta.d.ts +13 -0
- package/dist_ts/ts_migration/0001_authority_meta.js +44 -0
- package/dist_ts/ts_migration/0002_remove_backup_records.d.ts +17 -0
- package/dist_ts/ts_migration/0002_remove_backup_records.js +48 -0
- package/dist_ts/ts_migration/index.d.ts +6 -0
- package/dist_ts/ts_migration/index.js +12 -0
- package/package.json +3 -3
- package/readme.md +177 -14
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +262 -9
- package/ts/authority-runtime-contract.ts +14 -0
- package/ts/classes.accountlist.ts +3 -2
- package/ts/classes.authoritybroker.ts +385 -121
- package/ts/classes.authoritycli.ts +719 -0
- package/ts/classes.authorityclient.ts +75 -18
- package/ts/classes.authoritydaemon.ts +444 -36
- package/ts/classes.authoritydatabase.ts +1076 -75
- package/ts/classes.authoritymodels.ts +642 -144
- package/ts/classes.authoritypreuse.ts +296 -0
- package/ts/classes.authorityregistry.ts +47 -0
- package/ts/classes.authoritysecrets.ts +1 -1
- package/ts/classes.authorityusage.ts +527 -0
- package/ts/classes.claudeauthority.ts +537 -0
- package/ts/classes.claudecodeharness.ts +2 -1
- package/ts/classes.claudecodelocks.ts +70 -1
- package/ts/classes.claudenative.ts +785 -0
- package/ts/classes.claudestatus.ts +38 -9
- package/ts/classes.claudetokenrefresh.ts +82 -19
- package/ts/classes.codexharness.ts +3 -1
- package/ts/classes.codexmanaged.ts +76 -13
- package/ts/classes.codexpreuse.ts +33 -5
- package/ts/classes.fileharness.ts +7 -3
- package/ts/classes.limits.ts +11 -6
- package/ts/classes.opencodeharness.ts +1 -1
- package/ts/claudehttp.ts +31 -2
- package/ts/index.ts +6 -0
- package/ts/interfaces.harness.ts +6 -0
- package/ts/interfaces.list.ts +9 -3
- package/ts/plugins.ts +8 -3
- package/ts/ts_migration/0001_authority_meta.ts +45 -0
- package/ts/ts_migration/0002_remove_backup_records.ts +49 -0
- package/ts/ts_migration/index.ts +12 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IAuthSwitchClaudeNativeHandoff } from './authority-contract.js';
|
|
3
|
+
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
4
|
+
import type { IStoredAuthorityAccount, IStoredAuthorityClaudeHandoff, IStoredAuthorityClaudeHome,
|
|
5
|
+
IStoredAuthorityGrant, IStoredAuthorityNativePair } from './classes.authoritymodels.js';
|
|
6
|
+
import { ClaudeNativeAdapter, type IClaudeNativeIdentity, type IClaudeNativeLogin,
|
|
7
|
+
type IClaudeNativeLockedSession, type IClaudeNativeSnapshot } from './classes.claudenative.js';
|
|
8
|
+
import { ClaudeTokenRefresh } from './classes.claudetokenrefresh.js';
|
|
9
|
+
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
10
|
+
import type { IClaudeLogin } from './classes.claudestatus.js';
|
|
11
|
+
import type { IAuthSwitchUsageContext, IAuthSwitchUsageSnapshot } from './classes.authorityusage.js';
|
|
12
|
+
|
|
13
|
+
export interface IClaudeNativeAuthorityOptions {
|
|
14
|
+
homeId: string;
|
|
15
|
+
adapter: ClaudeNativeAdapter;
|
|
16
|
+
secrets?: IAuthSwitchSecretCodec;
|
|
17
|
+
refresh?: ClaudeTokenRefresh;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
onChanged?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type TClaudeChange = Parameters<AuthSwitchAuthorityDatabase['changeClaudeHandoff']>[0]['change'];
|
|
23
|
+
|
|
24
|
+
const hash = (value: string): string => plugins.crypto.createHash('sha256').update(value).digest('hex');
|
|
25
|
+
const isHash = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
26
|
+
const sameIdentity = (left: IClaudeNativeIdentity | null, right: IClaudeNativeIdentity): boolean =>
|
|
27
|
+
left?.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
|
|
28
|
+
const samePair = (left: IStoredAuthorityNativePair, right: IStoredAuthorityNativePair): boolean =>
|
|
29
|
+
left.credentialDigest === right.credentialDigest && left.configDigest === right.configDigest;
|
|
30
|
+
const identityFor = (account: IStoredAuthorityAccount): IClaudeNativeIdentity => ({
|
|
31
|
+
accountUuid: account.subject, organizationUuid: account.workspaceId,
|
|
32
|
+
});
|
|
33
|
+
const publicHandoff = (handoff: IStoredAuthorityClaudeHandoff): IAuthSwitchClaudeNativeHandoff => ({
|
|
34
|
+
id: handoff.id, homeId: handoff.homeId, outgoingAccountId: handoff.outgoingAccountId,
|
|
35
|
+
incomingAccountId: handoff.incomingAccountId, phase: handoff.phase, problem: handoff.problem,
|
|
36
|
+
runningEffectiveAuth: handoff.runningEffectiveAuth, updatedAt: handoff.updatedAt,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Daemon-owned Claude refresh and a journaled transfer of one native home between two grants. */
|
|
40
|
+
export class ClaudeNativeAuthority {
|
|
41
|
+
private readonly secrets: IAuthSwitchSecretCodec;
|
|
42
|
+
private readonly refresh: ClaudeTokenRefresh;
|
|
43
|
+
private readonly now: () => number;
|
|
44
|
+
private readonly activeRefreshes = new Map<string, Promise<void>>();
|
|
45
|
+
private readonly activeHandoffs = new Set<Promise<IAuthSwitchClaudeNativeHandoff>>();
|
|
46
|
+
private closed = false;
|
|
47
|
+
|
|
48
|
+
constructor(private readonly database: AuthSwitchAuthorityDatabase,
|
|
49
|
+
private readonly options: IClaudeNativeAuthorityOptions) {
|
|
50
|
+
if (!isHash(options.homeId)) throw new Error('Invalid configured Claude native home ID.');
|
|
51
|
+
this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
|
|
52
|
+
this.refresh = options.refresh ?? new ClaudeTokenRefresh();
|
|
53
|
+
this.now = options.now ?? Date.now;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private timestamp(): string { return new Date(this.now()).toISOString(); }
|
|
57
|
+
private changed(): void { this.options.onChanged?.(); }
|
|
58
|
+
private eventIds(): readonly [string, string] { return [plugins.crypto.randomUUID(), plugins.crypto.randomUUID()]; }
|
|
59
|
+
private grantStep(grant: IStoredAuthorityGrant, updateId: string, now: string,
|
|
60
|
+
changes: Partial<IStoredAuthorityGrant> = {}): IStoredAuthorityGrant {
|
|
61
|
+
return { ...grant, ...changes, revision: grant.revision + 1, statusObservedAt: now, updateId };
|
|
62
|
+
}
|
|
63
|
+
private homeStep(home: IStoredAuthorityClaudeHome, updateId: string,
|
|
64
|
+
changes: Partial<IStoredAuthorityClaudeHome>): IStoredAuthorityClaudeHome {
|
|
65
|
+
return { ...home, ...changes, revision: home.revision + 1, updateId };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async sealLogin(grantId: string, login: IClaudeNativeLogin): Promise<string> {
|
|
69
|
+
const bytes = Buffer.from(JSON.stringify(login), 'utf8');
|
|
70
|
+
try { return await this.secrets.seal(grantId, bytes); }
|
|
71
|
+
finally { bytes.fill(0); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async unsealLogin(account: IStoredAuthorityAccount, grant: IStoredAuthorityGrant): Promise<IClaudeNativeLogin> {
|
|
75
|
+
if (grant.owner !== 'daemon' || grant.purpose !== 'claude_host_native' || grant.accountId !== account.id
|
|
76
|
+
|| !grant.ciphertext) throw new Error('Claude grant is not available to the daemon.');
|
|
77
|
+
const bytes = await this.secrets.unseal(grant.id, grant.ciphertext);
|
|
78
|
+
try {
|
|
79
|
+
const value: unknown = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
80
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
81
|
+
const candidate = value as Record<string, unknown>;
|
|
82
|
+
if (!candidate.claudeAiOauth || typeof candidate.claudeAiOauth !== 'object'
|
|
83
|
+
|| Array.isArray(candidate.claudeAiOauth) || !candidate.oauthAccount
|
|
84
|
+
|| typeof candidate.oauthAccount !== 'object' || Array.isArray(candidate.oauthAccount)) throw new Error();
|
|
85
|
+
const login: IClaudeNativeLogin = {
|
|
86
|
+
claudeAiOauth: candidate.claudeAiOauth as Record<string, unknown>,
|
|
87
|
+
oauthAccount: candidate.oauthAccount as Record<string, unknown>,
|
|
88
|
+
};
|
|
89
|
+
if (login.oauthAccount.accountUuid !== account.subject
|
|
90
|
+
|| login.oauthAccount.organizationUuid !== account.workspaceId) throw new Error();
|
|
91
|
+
return login;
|
|
92
|
+
} catch { throw new Error('Sealed Claude grant is invalid or belongs to another account.'); }
|
|
93
|
+
finally { bytes.fill(0); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async change(operationId: string, incomingGrantId: string,
|
|
97
|
+
make: (current: Parameters<TClaudeChange>[0], updateId: string) => ReturnType<TClaudeChange>) {
|
|
98
|
+
const updateId = plugins.crypto.randomUUID();
|
|
99
|
+
const result = await this.database.changeClaudeHandoff({ operationId, homeId: this.options.homeId,
|
|
100
|
+
incomingGrantId, updateId, eventIds: this.eventIds(), change: current => make(current, updateId) });
|
|
101
|
+
this.changed();
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public async start(): Promise<void> {
|
|
106
|
+
await this.reconcilePending();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public async refreshInactiveGrant(grantId: string, rejectedGrantGeneration?: number,
|
|
110
|
+
minValidityMs = 300_000,
|
|
111
|
+
expectedGrant?: Pick<IStoredAuthorityGrant, 'revision' | 'grantGeneration' | 'ciphertext'>): Promise<void> {
|
|
112
|
+
if (!isHash(grantId) || this.closed || !Number.isSafeInteger(minValidityMs)
|
|
113
|
+
|| minValidityMs < 0 || minValidityMs > 3_600_000
|
|
114
|
+
|| (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
|
|
115
|
+
|| rejectedGrantGeneration < 1))) throw new Error('Invalid or closed Claude refresh request.');
|
|
116
|
+
const existing = this.activeRefreshes.get(grantId);
|
|
117
|
+
if (existing) return existing;
|
|
118
|
+
const task = this.performRefresh(grantId, rejectedGrantGeneration, minValidityMs, expectedGrant).finally(() => {
|
|
119
|
+
if (this.activeRefreshes.get(grantId) === task) this.activeRefreshes.delete(grantId);
|
|
120
|
+
});
|
|
121
|
+
this.activeRefreshes.set(grantId, task);
|
|
122
|
+
return task;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async performRefresh(grantId: string, rejectedGrantGeneration?: number,
|
|
126
|
+
minValidityMs = 300_000,
|
|
127
|
+
expectedGrant?: Pick<IStoredAuthorityGrant, 'revision' | 'grantGeneration' | 'ciphertext'>): Promise<void> {
|
|
128
|
+
const grant = await this.database.readGrant(grantId);
|
|
129
|
+
if (expectedGrant && (!grant || grant.revision !== expectedGrant.revision
|
|
130
|
+
|| grant.grantGeneration !== expectedGrant.grantGeneration
|
|
131
|
+
|| grant.ciphertext !== expectedGrant.ciphertext)) {
|
|
132
|
+
throw new Error('Claude grant changed before status refresh.');
|
|
133
|
+
}
|
|
134
|
+
if (!grant || grant.purpose !== 'claude_host_native' || grant.owner !== 'daemon'
|
|
135
|
+
|| !['ready', 'retry_wait'].includes(grant.state)
|
|
136
|
+
|| (grant.state === 'retry_wait' && (grant.retryAt === null || Date.parse(grant.retryAt) > this.now()))) return;
|
|
137
|
+
const account = await this.database.readAccount(grant.accountId);
|
|
138
|
+
if (!account || account.removed) return;
|
|
139
|
+
if (!(await this.database.hasVerifiedClaudeGrantReceipt(account.id, grant.id))) {
|
|
140
|
+
throw new Error('Claude refresh requires a verified import receipt.');
|
|
141
|
+
}
|
|
142
|
+
const login = await this.unsealLogin(account, grant);
|
|
143
|
+
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
144
|
+
throw new Error('Claude rejected a future grant generation.');
|
|
145
|
+
}
|
|
146
|
+
if (!this.refresh.due(login.claudeAiOauth, minValidityMs)
|
|
147
|
+
&& rejectedGrantGeneration !== grant.grantGeneration) return;
|
|
148
|
+
const attemptId = plugins.crypto.randomUUID();
|
|
149
|
+
await this.database.changeGrant(attemptId, grant.id, current => {
|
|
150
|
+
if (current.revision !== grant.revision || current.state !== grant.state
|
|
151
|
+
|| current.owner !== 'daemon' || current.grantGeneration !== grant.grantGeneration) {
|
|
152
|
+
throw new Error('Claude grant changed before refresh began.');
|
|
153
|
+
}
|
|
154
|
+
return { ...current, state: 'exchange_may_have_been_sent', attemptId, retryAt: null,
|
|
155
|
+
revision: current.revision + 1, statusObservedAt: this.timestamp(), updateId: attemptId };
|
|
156
|
+
});
|
|
157
|
+
this.changed();
|
|
158
|
+
const outcome = await this.refresh.refreshForAuthority(login.claudeAiOauth);
|
|
159
|
+
if (!outcome.success) {
|
|
160
|
+
const updateId = plugins.crypto.randomUUID();
|
|
161
|
+
const retry = outcome.requestOutcome === 'notSent' && outcome.reason === 'provider_unavailable';
|
|
162
|
+
await this.database.changeGrant(updateId, grant.id, current => {
|
|
163
|
+
if (current.state !== 'exchange_may_have_been_sent' || current.attemptId !== attemptId) {
|
|
164
|
+
throw new Error('Claude grant changed during refresh.');
|
|
165
|
+
}
|
|
166
|
+
return { ...current, state: retry ? 'retry_wait' : 'needs_reauth', attemptId: null,
|
|
167
|
+
retryAt: retry ? new Date(this.now() + Math.min(30_000 * 2 ** Math.min(current.retryCount, 10), 1_800_000)).toISOString() : null,
|
|
168
|
+
retryCount: retry ? current.retryCount + 1 : current.retryCount,
|
|
169
|
+
problem: retry ? 'provider_unavailable' : 'exchange_uncertain',
|
|
170
|
+
revision: current.revision + 1, statusObservedAt: this.timestamp(), updateId };
|
|
171
|
+
});
|
|
172
|
+
this.changed();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const refreshed: IClaudeNativeLogin = { oauthAccount: login.oauthAccount,
|
|
176
|
+
claudeAiOauth: { ...login.claudeAiOauth, ...outcome.tokens } };
|
|
177
|
+
const ciphertext = await this.sealLogin(grant.id, refreshed);
|
|
178
|
+
const updateId = plugins.crypto.randomUUID();
|
|
179
|
+
try {
|
|
180
|
+
await this.database.changeGrant(updateId, grant.id, current => {
|
|
181
|
+
if (current.state !== 'exchange_may_have_been_sent' || current.attemptId !== attemptId) {
|
|
182
|
+
throw new Error('Claude grant changed during refresh commit.');
|
|
183
|
+
}
|
|
184
|
+
return { ...current, state: 'ready', attemptId: null, retryAt: null, retryCount: 0,
|
|
185
|
+
problem: 'none', ciphertext, accessExpiresAt: new Date(outcome.tokens.expiresAt).toISOString(),
|
|
186
|
+
grantGeneration: current.grantGeneration + 1, revision: current.revision + 1,
|
|
187
|
+
statusObservedAt: this.timestamp(), updateId };
|
|
188
|
+
});
|
|
189
|
+
this.changed();
|
|
190
|
+
} catch {
|
|
191
|
+
// The pre-send marker is durable. Startup makes an unresolved rotation needs_reauth.
|
|
192
|
+
throw new Error('Claude refresh commit is uncertain; the previous token will not be replayed.');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private statusLogin(account: IStoredAuthorityAccount, login: IClaudeNativeLogin): IClaudeLogin {
|
|
197
|
+
const oauth = login.claudeAiOauth;
|
|
198
|
+
if (typeof oauth.accessToken !== 'string' || !oauth.accessToken
|
|
199
|
+
|| typeof oauth.expiresAt !== 'number' || !Number.isFinite(oauth.expiresAt)
|
|
200
|
+
|| !Array.isArray(oauth.scopes) || !oauth.scopes.every(scope => typeof scope === 'string')
|
|
201
|
+
|| login.oauthAccount.accountUuid !== account.subject
|
|
202
|
+
|| login.oauthAccount.organizationUuid !== account.workspaceId) {
|
|
203
|
+
throw new Error('Claude status login is incomplete or belongs to another account.');
|
|
204
|
+
}
|
|
205
|
+
return { accessToken: oauth.accessToken, scopes: [...oauth.scopes],
|
|
206
|
+
identity: identityFor(account),
|
|
207
|
+
...(typeof oauth.subscriptionType === 'string' ? { plan: oauth.subscriptionType }
|
|
208
|
+
: account.plan ? { plan: account.plan } : {}) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Backend-only inactive access. Only this owner may rotate, with a durable pre-send marker. */
|
|
212
|
+
public async resolveInactiveStatusLogin(context: IAuthSwitchUsageContext,
|
|
213
|
+
rejectedGrantGeneration: number | undefined, minValidityMs: number): Promise<{
|
|
214
|
+
login: IClaudeLogin; grantGeneration: number; expiresAt: string | null;
|
|
215
|
+
}> {
|
|
216
|
+
if (this.closed || !isHash(context.accountId) || !isHash(context.loginId)
|
|
217
|
+
|| !Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > 3_600_000) {
|
|
218
|
+
throw new Error('Invalid or closed Claude status request.');
|
|
219
|
+
}
|
|
220
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
221
|
+
const view = await this.database.readUsageAccessView(context.accountId, context.loginId);
|
|
222
|
+
if (!view || view.context.epoch !== context.epoch
|
|
223
|
+
|| view.context.authorizationGeneration !== context.authorizationGeneration
|
|
224
|
+
|| view.account.subject !== context.subjectId
|
|
225
|
+
|| view.account.workspaceId !== context.workspaceId || view.account.removed
|
|
226
|
+
|| view.account.providerId !== 'anthropic' || view.grant.providerId !== 'anthropic'
|
|
227
|
+
|| view.grant.purpose !== 'claude_host_native' || view.grant.owner !== 'daemon'
|
|
228
|
+
|| !['ready', 'retry_wait'].includes(view.grant.state)) {
|
|
229
|
+
throw new Error('Inactive Claude status authority changed.');
|
|
230
|
+
}
|
|
231
|
+
if (!(await this.database.hasVerifiedClaudeGrantReceipt(view.account.id, view.grant.id))) {
|
|
232
|
+
throw new Error('Claude status requires a verified import receipt.');
|
|
233
|
+
}
|
|
234
|
+
const currentLogin = this.statusLogin(view.account, await this.unsealLogin(view.account, view.grant));
|
|
235
|
+
if (!currentLogin.scopes.includes('user:profile')) {
|
|
236
|
+
const confirmed = await this.database.readUsageAccessView(context.accountId, context.loginId);
|
|
237
|
+
if (!confirmed || confirmed.context.epoch !== context.epoch
|
|
238
|
+
|| confirmed.context.authorizationGeneration !== context.authorizationGeneration
|
|
239
|
+
|| confirmed.account.subject !== view.account.subject
|
|
240
|
+
|| confirmed.account.workspaceId !== view.account.workspaceId || confirmed.account.removed
|
|
241
|
+
|| confirmed.grant.revision !== view.grant.revision
|
|
242
|
+
|| confirmed.grant.grantGeneration !== view.grant.grantGeneration
|
|
243
|
+
|| confirmed.grant.ciphertext !== view.grant.ciphertext
|
|
244
|
+
|| confirmed.grant.owner !== 'daemon' || !['ready', 'retry_wait'].includes(confirmed.grant.state)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
return { login: currentLogin, grantGeneration: view.grant.grantGeneration,
|
|
248
|
+
expiresAt: view.grant.accessExpiresAt };
|
|
249
|
+
}
|
|
250
|
+
await this.refreshInactiveGrant(view.grant.id, rejectedGrantGeneration, minValidityMs, view.grant);
|
|
251
|
+
const latest = await this.database.readUsageAccessView(context.accountId, context.loginId);
|
|
252
|
+
if (!latest || latest.context.epoch !== context.epoch
|
|
253
|
+
|| latest.context.authorizationGeneration !== context.authorizationGeneration
|
|
254
|
+
|| latest.account.subject !== context.subjectId
|
|
255
|
+
|| latest.account.subject !== view.account.subject
|
|
256
|
+
|| latest.account.workspaceId !== view.account.workspaceId || latest.account.removed
|
|
257
|
+
|| latest.grant.owner !== 'daemon' || latest.grant.purpose !== 'claude_host_native'
|
|
258
|
+
|| !['ready', 'retry_wait'].includes(latest.grant.state)) {
|
|
259
|
+
throw new Error('Inactive Claude status authority changed during refresh.');
|
|
260
|
+
}
|
|
261
|
+
const expiresAt = latest.grant.accessExpiresAt;
|
|
262
|
+
if (!expiresAt || Date.parse(expiresAt) <= this.now() + minValidityMs
|
|
263
|
+
|| (rejectedGrantGeneration !== undefined
|
|
264
|
+
&& latest.grant.grantGeneration <= rejectedGrantGeneration)) continue;
|
|
265
|
+
const login = this.statusLogin(latest.account, await this.unsealLogin(latest.account, latest.grant));
|
|
266
|
+
const confirmed = await this.database.readUsageAccessView(context.accountId, context.loginId);
|
|
267
|
+
if (!confirmed || confirmed.context.epoch !== context.epoch
|
|
268
|
+
|| confirmed.context.authorizationGeneration !== context.authorizationGeneration
|
|
269
|
+
|| confirmed.account.subject !== latest.account.subject
|
|
270
|
+
|| confirmed.account.workspaceId !== latest.account.workspaceId
|
|
271
|
+
|| confirmed.grant.revision !== latest.grant.revision
|
|
272
|
+
|| confirmed.grant.grantGeneration !== latest.grant.grantGeneration
|
|
273
|
+
|| confirmed.grant.ciphertext !== latest.grant.ciphertext
|
|
274
|
+
|| confirmed.grant.owner !== 'daemon' || !['ready', 'retry_wait'].includes(confirmed.grant.state)) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
return { login, grantGeneration: latest.grant.grantGeneration, expiresAt };
|
|
278
|
+
}
|
|
279
|
+
throw new Error('Inactive Claude status credential is not safely available.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Keep the exact native source and qualified vendor locks through status GET and publication. */
|
|
283
|
+
public async withNativeUsageSource(context: IAuthSwitchUsageContext,
|
|
284
|
+
action: (source: { context: IAuthSwitchUsageContext; login: IClaudeLogin;
|
|
285
|
+
readContext(): Promise<IAuthSwitchUsageContext | null> }) => Promise<IAuthSwitchUsageSnapshot>): Promise<IAuthSwitchUsageSnapshot> {
|
|
286
|
+
if (this.closed || !isHash(context.accountId) || !isHash(context.loginId)) {
|
|
287
|
+
throw new Error('Invalid or closed Claude native status request.');
|
|
288
|
+
}
|
|
289
|
+
return this.options.adapter.withExclusiveHome(async session => {
|
|
290
|
+
const inspect = async (): Promise<{ context: IAuthSwitchUsageContext; login: IClaudeLogin } | null> => {
|
|
291
|
+
const view = await this.database.readClaudeNativeUsageView(this.options.homeId,
|
|
292
|
+
context.accountId, context.loginId);
|
|
293
|
+
if (!view || view.context.epoch !== context.epoch
|
|
294
|
+
|| view.context.authorizationGeneration !== context.authorizationGeneration
|
|
295
|
+
|| view.context.grantGeneration !== context.grantGeneration
|
|
296
|
+
|| view.account.subject !== context.subjectId
|
|
297
|
+
|| view.account.workspaceId !== context.workspaceId) return null;
|
|
298
|
+
const snapshot = session.readStable();
|
|
299
|
+
if (!snapshot.login || !snapshot.identity || !snapshot.credentialDigest || !snapshot.configDigest
|
|
300
|
+
|| !sameIdentity(snapshot.identity, identityFor(view.account))) return null;
|
|
301
|
+
const login = this.statusLogin(view.account, snapshot.login);
|
|
302
|
+
if (typeof snapshot.login.claudeAiOauth.expiresAt !== 'number'
|
|
303
|
+
|| snapshot.login.claudeAiOauth.expiresAt <= this.now()) return null;
|
|
304
|
+
return { context: { ...view.context, nativeSource: {
|
|
305
|
+
homeId: this.options.homeId, credentialDigest: snapshot.credentialDigest,
|
|
306
|
+
configDigest: snapshot.configDigest, accessTokenDigest: hash(login.accessToken),
|
|
307
|
+
} }, login };
|
|
308
|
+
};
|
|
309
|
+
const initial = await inspect();
|
|
310
|
+
if (!initial) throw new Error('Qualified Claude native status source is unavailable.');
|
|
311
|
+
const result = await action({ ...initial, readContext: async () => (await inspect())?.context ?? null });
|
|
312
|
+
const final = await inspect();
|
|
313
|
+
if (!final || final.context.epoch !== initial.context.epoch
|
|
314
|
+
|| final.context.accountId !== initial.context.accountId
|
|
315
|
+
|| final.context.loginId !== initial.context.loginId
|
|
316
|
+
|| final.context.subjectId !== initial.context.subjectId
|
|
317
|
+
|| final.context.workspaceId !== initial.context.workspaceId
|
|
318
|
+
|| final.context.providerId !== initial.context.providerId
|
|
319
|
+
|| final.context.purpose !== initial.context.purpose
|
|
320
|
+
|| final.context.owner !== initial.context.owner
|
|
321
|
+
|| final.context.grantState !== initial.context.grantState
|
|
322
|
+
|| final.context.removed !== initial.context.removed
|
|
323
|
+
|| final.context.authorizationGeneration !== initial.context.authorizationGeneration
|
|
324
|
+
|| final.context.grantGeneration !== initial.context.grantGeneration
|
|
325
|
+
|| final.context.nativeSource?.credentialDigest !== initial.context.nativeSource?.credentialDigest
|
|
326
|
+
|| final.context.nativeSource?.configDigest !== initial.context.nativeSource?.configDigest
|
|
327
|
+
|| final.context.nativeSource?.accessTokenDigest !== initial.context.nativeSource?.accessTokenDigest) {
|
|
328
|
+
throw new Error('Claude native status source changed while it was read.');
|
|
329
|
+
}
|
|
330
|
+
return result;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async reserve(operationId: string, targetAccountId: string, incomingGrantId: string): Promise<void> {
|
|
335
|
+
await this.change(operationId, incomingGrantId, ({ home, outgoing, incoming, outgoingAccount, incomingAccount }, updateId) => {
|
|
336
|
+
if (incoming.accountId !== targetAccountId || incomingAccount.id !== targetAccountId) {
|
|
337
|
+
throw new Error('Claude login does not belong to the selected account.');
|
|
338
|
+
}
|
|
339
|
+
const now = this.timestamp();
|
|
340
|
+
const handoff: IStoredAuthorityClaudeHandoff = {
|
|
341
|
+
id: operationId, homeId: home.id, outgoingAccountId: outgoing.accountId, outgoingGrantId: outgoing.id,
|
|
342
|
+
incomingAccountId: incoming.accountId, incomingGrantId: incoming.id, phase: 'reserved',
|
|
343
|
+
before: null, after: null, sourceIdentity: identityFor(outgoingAccount),
|
|
344
|
+
targetIdentity: identityFor(incomingAccount), sourceAccessDigest: null, targetAccessDigest: null,
|
|
345
|
+
sealedOutgoing: null, runningEffectiveAuth: null, problem: 'none', startedAt: now,
|
|
346
|
+
updatedAt: now, revision: 1, updateId,
|
|
347
|
+
};
|
|
348
|
+
return { home: this.homeStep(home, updateId, { pendingOperationId: operationId }), handoff,
|
|
349
|
+
outgoing: this.grantStep(outgoing, updateId, now),
|
|
350
|
+
incoming: this.grantStep(incoming, updateId, now, { state: 'handoff_pending' }) };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private async prepare(operationId: string, incomingGrantId: string,
|
|
355
|
+
before: IStoredAuthorityNativePair, after: IStoredAuthorityNativePair,
|
|
356
|
+
sourceAccessDigest: string, targetAccessDigest: string, sealedOutgoing: string,
|
|
357
|
+
runningEffectiveAuth: IClaudeNativeSnapshot['runningEffectiveAuth']): Promise<void> {
|
|
358
|
+
await this.change(operationId, incomingGrantId, ({ home, handoff, outgoing, incoming }, updateId) => {
|
|
359
|
+
if (!handoff || handoff.phase !== 'reserved') throw new Error('Claude handoff reservation changed.');
|
|
360
|
+
const now = this.timestamp();
|
|
361
|
+
return { home: this.homeStep(home, updateId, {}),
|
|
362
|
+
handoff: { ...handoff, phase: 'prepared', before, after, sourceAccessDigest,
|
|
363
|
+
targetAccessDigest, sealedOutgoing, runningEffectiveAuth, updatedAt: now,
|
|
364
|
+
revision: handoff.revision + 1, updateId },
|
|
365
|
+
outgoing: this.grantStep(outgoing, updateId, now, { state: 'handoff_pending' }),
|
|
366
|
+
incoming: this.grantStep(incoming, updateId, now) };
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async settle(operationId: string, phase: 'committed' | 'aborted' | 'quarantined',
|
|
371
|
+
runningEffectiveAuth: IClaudeNativeSnapshot['runningEffectiveAuth'] | null,
|
|
372
|
+
problem: IStoredAuthorityClaudeHandoff['problem'] = 'none'): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
373
|
+
const context = await this.database.readClaudeHandoffContext(operationId);
|
|
374
|
+
if (!context) throw new Error('Claude handoff operation is missing.');
|
|
375
|
+
if (['committed', 'aborted', 'quarantined'].includes(context.handoff.phase)) return publicHandoff(context.handoff);
|
|
376
|
+
let outgoingExpiry: string | null = null;
|
|
377
|
+
if (phase === 'committed') {
|
|
378
|
+
if (!context.handoff.sealedOutgoing) throw new Error('Claude outgoing grant has no sealed journal proof.');
|
|
379
|
+
const outgoingGrant = { ...context.outgoing, owner: 'daemon' as const,
|
|
380
|
+
ciphertext: context.handoff.sealedOutgoing };
|
|
381
|
+
const login = await this.unsealLogin(context.outgoingAccount, outgoingGrant);
|
|
382
|
+
const expires = login.claudeAiOauth.expiresAt;
|
|
383
|
+
if (typeof expires !== 'number' || !Number.isFinite(expires)) throw new Error('Sealed outgoing Claude expiry is invalid.');
|
|
384
|
+
outgoingExpiry = new Date(expires).toISOString();
|
|
385
|
+
}
|
|
386
|
+
const result = await this.change(operationId, context.incoming.id, ({ home, handoff, outgoing, incoming }, updateId) => {
|
|
387
|
+
if (!handoff || !['reserved', 'prepared'].includes(handoff.phase)) throw new Error('Claude handoff phase changed.');
|
|
388
|
+
const now = this.timestamp();
|
|
389
|
+
const committed = phase === 'committed';
|
|
390
|
+
const quarantined = phase === 'quarantined';
|
|
391
|
+
return {
|
|
392
|
+
home: this.homeStep(home, updateId, committed
|
|
393
|
+
? { activeAccountId: incoming.accountId, activeGrantId: incoming.id,
|
|
394
|
+
pendingOperationId: null, status: 'ready' }
|
|
395
|
+
: quarantined ? { status: 'quarantined' }
|
|
396
|
+
: { pendingOperationId: null, status: 'ready' }),
|
|
397
|
+
handoff: { ...handoff, phase, sealedOutgoing: quarantined ? handoff.sealedOutgoing : null,
|
|
398
|
+
runningEffectiveAuth, problem, updatedAt: now, revision: handoff.revision + 1, updateId },
|
|
399
|
+
outgoing: this.grantStep(outgoing, updateId, now, committed
|
|
400
|
+
? { state: 'ready', owner: 'daemon', ciphertext: handoff.sealedOutgoing,
|
|
401
|
+
accessExpiresAt: outgoingExpiry, problem: 'none',
|
|
402
|
+
grantGeneration: outgoing.grantGeneration + 1,
|
|
403
|
+
authorizationGeneration: outgoing.authorizationGeneration + 1 }
|
|
404
|
+
: quarantined ? { state: 'handoff_quarantined' }
|
|
405
|
+
: { state: 'native' }),
|
|
406
|
+
incoming: this.grantStep(incoming, updateId, now, committed
|
|
407
|
+
? { state: 'native', owner: 'claude_native', ciphertext: null, accessExpiresAt: null,
|
|
408
|
+
problem: 'native_owner', grantGeneration: incoming.grantGeneration + 1,
|
|
409
|
+
authorizationGeneration: incoming.authorizationGeneration + 1 }
|
|
410
|
+
: quarantined ? { state: 'handoff_quarantined' }
|
|
411
|
+
: { state: 'ready' }),
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
return publicHandoff(result.handoff);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async classify(operationId: string, session: IClaudeNativeLockedSession): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
418
|
+
const context = await this.database.readClaudeHandoffContext(operationId);
|
|
419
|
+
if (!context) throw new Error('Claude handoff operation is missing.');
|
|
420
|
+
const handoff = context.handoff;
|
|
421
|
+
if (['committed', 'aborted', 'quarantined'].includes(handoff.phase)) return publicHandoff(handoff);
|
|
422
|
+
let observed: IClaudeNativeSnapshot;
|
|
423
|
+
try { observed = session.readStable(); }
|
|
424
|
+
catch { return this.settle(operationId, 'quarantined', null, 'foreign_or_torn'); }
|
|
425
|
+
if (handoff.phase === 'reserved') {
|
|
426
|
+
return sameIdentity(observed.identity, handoff.sourceIdentity)
|
|
427
|
+
? this.settle(operationId, 'aborted', observed.runningEffectiveAuth)
|
|
428
|
+
: this.settle(operationId, 'quarantined', observed.runningEffectiveAuth, 'foreign_or_torn');
|
|
429
|
+
}
|
|
430
|
+
const access = observed.login?.claudeAiOauth.accessToken;
|
|
431
|
+
const accessDigest = typeof access === 'string' ? hash(access) : null;
|
|
432
|
+
if (handoff.before && samePair(observed, handoff.before)
|
|
433
|
+
&& sameIdentity(observed.identity, handoff.sourceIdentity)
|
|
434
|
+
&& accessDigest === handoff.sourceAccessDigest) {
|
|
435
|
+
return this.settle(operationId, 'aborted', observed.runningEffectiveAuth);
|
|
436
|
+
}
|
|
437
|
+
if (handoff.after && samePair(observed, handoff.after)
|
|
438
|
+
&& sameIdentity(observed.identity, handoff.targetIdentity)
|
|
439
|
+
&& accessDigest === handoff.targetAccessDigest) {
|
|
440
|
+
return this.settle(operationId, 'committed', observed.runningEffectiveAuth);
|
|
441
|
+
}
|
|
442
|
+
return this.settle(operationId, 'quarantined', observed.runningEffectiveAuth, 'foreign_or_torn');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
public async reconcileOperation(operationId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
446
|
+
const context = await this.database.readClaudeHandoffContext(operationId);
|
|
447
|
+
if (!context || context.handoff.homeId !== this.options.homeId) throw new Error('Claude handoff is unavailable on this home.');
|
|
448
|
+
if (['committed', 'aborted', 'quarantined'].includes(context.handoff.phase)) return publicHandoff(context.handoff);
|
|
449
|
+
try {
|
|
450
|
+
return await this.options.adapter.withExclusiveHome(session => this.classify(operationId, session));
|
|
451
|
+
} catch {
|
|
452
|
+
return this.settle(operationId, 'quarantined', null, 'unsupported_effective_auth');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
public async reconcilePending(): Promise<void> {
|
|
457
|
+
for (const phase of ['reserved', 'prepared'] as const) {
|
|
458
|
+
let after: string | null = null;
|
|
459
|
+
while (true) {
|
|
460
|
+
const page = await this.database.claudeHandoffsByPhase(phase, after);
|
|
461
|
+
if (!page.length) break;
|
|
462
|
+
for (const handoff of page) {
|
|
463
|
+
if (handoff.homeId === this.options.homeId) await this.reconcileOperation(handoff.id);
|
|
464
|
+
}
|
|
465
|
+
after = page.at(-1)!.id;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
public async switchTo(accountId: string, loginId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
471
|
+
if (!isHash(accountId) || !isHash(loginId) || this.closed) {
|
|
472
|
+
throw new Error('Invalid or closed Claude handoff request.');
|
|
473
|
+
}
|
|
474
|
+
const task = this.performSwitch(accountId, loginId);
|
|
475
|
+
this.activeHandoffs.add(task);
|
|
476
|
+
try { return await task; }
|
|
477
|
+
finally { this.activeHandoffs.delete(task); }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async performSwitch(accountId: string, loginId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
481
|
+
const home = await this.database.readClaudeHome(this.options.homeId);
|
|
482
|
+
if (!home || home.status !== 'ready' || home.pendingOperationId !== null) {
|
|
483
|
+
throw new Error('Verified Claude native home is unavailable or has a pending handoff.');
|
|
484
|
+
}
|
|
485
|
+
await this.refreshInactiveGrant(loginId);
|
|
486
|
+
const operationId = plugins.crypto.randomUUID();
|
|
487
|
+
await this.reserve(operationId, accountId, loginId);
|
|
488
|
+
try {
|
|
489
|
+
return await this.options.adapter.withExclusiveHome(async session => {
|
|
490
|
+
const before = session.readStable();
|
|
491
|
+
const context = await this.database.readClaudeHandoffContext(operationId);
|
|
492
|
+
if (!context || !sameIdentity(before.identity, context.handoff.sourceIdentity) || !before.login) {
|
|
493
|
+
return this.classify(operationId, session);
|
|
494
|
+
}
|
|
495
|
+
let outgoingProof: Awaited<ReturnType<IClaudeNativeLockedSession['verifyProfileIdentity']>>;
|
|
496
|
+
try { outgoingProof = await session.verifyProfileIdentity(before, context.handoff.sourceIdentity); }
|
|
497
|
+
catch {
|
|
498
|
+
return this.settle(operationId, 'quarantined', before.runningEffectiveAuth, 'foreign_or_torn');
|
|
499
|
+
}
|
|
500
|
+
const incomingLogin = await this.unsealLogin(context.incomingAccount, context.incoming);
|
|
501
|
+
const prepared = await session.prepareWrite({ expected: before,
|
|
502
|
+
expectedSourceIdentity: context.handoff.sourceIdentity, targetLogin: incomingLogin,
|
|
503
|
+
expectedIdentity: context.handoff.targetIdentity });
|
|
504
|
+
if (prepared.kind !== 'prepared') return this.classify(operationId, session);
|
|
505
|
+
const sealedOutgoing = await this.sealLogin(context.outgoing.id, before.login);
|
|
506
|
+
await this.prepare(operationId, loginId, prepared.plan.before, prepared.plan.after,
|
|
507
|
+
outgoingProof.accessTokenDigest, prepared.plan.profile.accessTokenDigest,
|
|
508
|
+
sealedOutgoing, before.runningEffectiveAuth);
|
|
509
|
+
session.writePreparedIfCurrent(prepared.plan);
|
|
510
|
+
return this.classify(operationId, session);
|
|
511
|
+
});
|
|
512
|
+
} catch {
|
|
513
|
+
const outcome = await this.reconcileOperation(operationId);
|
|
514
|
+
return { ...outcome, runningEffectiveAuth: 'unsupported_effective_auth' };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
public async getHandoff(operationId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
|
|
519
|
+
const handoff = await this.database.readClaudeHandoff(operationId);
|
|
520
|
+
if (!handoff || handoff.homeId !== this.options.homeId) throw new Error('Claude handoff operation was not found.');
|
|
521
|
+
return publicHandoff(handoff);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
public async listHandoffs(after: string | null, limit = 128): Promise<{
|
|
525
|
+
handoffs: IAuthSwitchClaudeNativeHandoff[]; nextCursor: string | null;
|
|
526
|
+
}> {
|
|
527
|
+
const page = await this.database.claudeHandoffsPage(after, limit);
|
|
528
|
+
return { handoffs: page.handoffs.filter(item => item.homeId === this.options.homeId).map(publicHandoff),
|
|
529
|
+
nextCursor: page.nextCursor };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
public async close(): Promise<void> {
|
|
533
|
+
this.closed = true;
|
|
534
|
+
await Promise.allSettled(this.activeHandoffs);
|
|
535
|
+
await Promise.allSettled(this.activeRefreshes.values());
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -104,7 +104,8 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
104
104
|
const account = credentialRecord(credential.oauthAccount);
|
|
105
105
|
if (!credentialText(oauth.accessToken) || !Array.isArray(oauth.scopes) || !oauth.scopes.every(scope => typeof scope === 'string')
|
|
106
106
|
|| !credentialText(account.accountUuid) || !credentialText(account.organizationUuid) || !credentialText(account.emailAddress)) throw new Error('Claude Code login or its matching account metadata is incomplete. Log in again with Claude Code before saving.');
|
|
107
|
-
return { slotId, identity: JSON.stringify([account.accountUuid, account.organizationUuid]),
|
|
107
|
+
return { slotId, identity: JSON.stringify([account.accountUuid, account.organizationUuid]),
|
|
108
|
+
providerAccountId: String(account.accountUuid), label: String(account.emailAddress).toLowerCase(), credential };
|
|
108
109
|
}
|
|
109
110
|
/** Why this installation's login is not a subscriber file login this adapter can manage; null when it is. */
|
|
110
111
|
private unavailableReason(): string | null {
|
|
@@ -24,6 +24,18 @@ export interface IClaudeCodeLocksOptions {
|
|
|
24
24
|
timeoutMs?: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** The vendor locks may be lost while an awaited database operation is in flight. */
|
|
28
|
+
export interface IClaudeCodeLockLease {
|
|
29
|
+
assertHeld(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A callback may already have changed native files when releasing its locks fails. */
|
|
33
|
+
export class ClaudeCodeLockOutcomeUnknownError extends Error {
|
|
34
|
+
constructor(public readonly actionCompleted: boolean) {
|
|
35
|
+
super('Claude Code native lock ownership could not be confirmed after the operation; reconcile its journal before retrying.');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
28
40
|
const RETRY_MS = 250;
|
|
29
41
|
|
|
@@ -110,12 +122,67 @@ export class ClaudeCodeLocks {
|
|
|
110
122
|
return note === null ? outcome.lines : [...outcome.lines, note];
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
/** Keep every vendor lock across awaited authority journal and native-file operations. */
|
|
126
|
+
public async holdAsync<T>(actionArg: (lease: IClaudeCodeLockLease) => Promise<T>,
|
|
127
|
+
optionsArg: { timeoutMs?: number } = {}): Promise<T> {
|
|
128
|
+
const lockfile = await plugins.loadProperLockfile();
|
|
129
|
+
const deadline = Date.now() + (optionsArg.timeoutMs ?? this.timeoutMs);
|
|
130
|
+
const held: IHeldLock[] = [];
|
|
131
|
+
const unreleased: string[] = [];
|
|
132
|
+
let compromised = false;
|
|
133
|
+
let active = false;
|
|
134
|
+
const lease: IClaudeCodeLockLease = {
|
|
135
|
+
assertHeld: () => {
|
|
136
|
+
if (!active || compromised) throw new ClaudeCodeLockOutcomeUnknownError(false);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const releaseAll = async (): Promise<void> => {
|
|
140
|
+
active = false;
|
|
141
|
+
for (const lock of held.splice(0).reverse()) {
|
|
142
|
+
try { await lock.release(); }
|
|
143
|
+
catch { unreleased.push(lock.lockfilePath); }
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
let completed = false;
|
|
147
|
+
let value: T | undefined;
|
|
148
|
+
let failure: unknown;
|
|
149
|
+
try {
|
|
150
|
+
for (const directory of new Set([this.configDir, plugins.path.dirname(this.configFile)])) {
|
|
151
|
+
plugins.fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
152
|
+
}
|
|
153
|
+
const locks = await this.locks();
|
|
154
|
+
for (;;) {
|
|
155
|
+
compromised = false;
|
|
156
|
+
const busy = await this.acquireAll(lockfile, locks, held, () => { compromised = true; }, true);
|
|
157
|
+
if (busy === null) {
|
|
158
|
+
active = true;
|
|
159
|
+
lease.assertHeld();
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
await releaseAll();
|
|
163
|
+
if (unreleased.length) throw new ClaudeCodeLockOutcomeUnknownError(false);
|
|
164
|
+
if (Date.now() >= deadline) {
|
|
165
|
+
throw new Error(`Claude Code is refreshing or saving its login (${busy.lockfilePath} is held); nothing was changed. Try again in a moment.`);
|
|
166
|
+
}
|
|
167
|
+
await new Promise<void>(resolve => { setTimeout(resolve, Math.min(RETRY_MS, Math.max(1, deadline - Date.now()))); });
|
|
168
|
+
}
|
|
169
|
+
value = await actionArg(lease);
|
|
170
|
+
completed = true;
|
|
171
|
+
lease.assertHeld();
|
|
172
|
+
} catch (error) { failure = error; }
|
|
173
|
+
await releaseAll();
|
|
174
|
+
if (unreleased.length) throw new ClaudeCodeLockOutcomeUnknownError(completed);
|
|
175
|
+
if (failure !== undefined) throw failure;
|
|
176
|
+
return value as T;
|
|
177
|
+
}
|
|
178
|
+
|
|
113
179
|
/** Takes the locks in order into `heldArg` and returns the first one another process holds, or null once all are held. */
|
|
114
180
|
private async acquireAll(
|
|
115
181
|
lockfileArg: plugins.TProperLockfile,
|
|
116
182
|
locksArg: readonly IClaudeCodeLock[],
|
|
117
183
|
heldArg: IHeldLock[],
|
|
118
184
|
onCompromisedArg: (errorArg: Error) => void,
|
|
185
|
+
requireAllArg = false,
|
|
119
186
|
): Promise<IClaudeCodeLock | null> {
|
|
120
187
|
for (const lock of locksArg) {
|
|
121
188
|
try {
|
|
@@ -125,7 +192,9 @@ export class ClaudeCodeLocks {
|
|
|
125
192
|
heldArg.push({ lockfilePath: lock.lockfilePath, release });
|
|
126
193
|
} catch (error) {
|
|
127
194
|
if (errorCode(error) === 'ELOCKED') return lock;
|
|
128
|
-
if (!lock.bestEffort)
|
|
195
|
+
if (requireAllArg || !lock.bestEffort) {
|
|
196
|
+
throw new Error(`Claude Code's lock ${lock.lockfilePath} could not be taken (${errorCode(error) ?? 'unknown error'}); nothing was changed.`);
|
|
197
|
+
}
|
|
129
198
|
}
|
|
130
199
|
}
|
|
131
200
|
return null;
|