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