@modelprofile.com/authswitch 9.2.0 → 10.0.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.
Files changed (81) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/authority-contract.d.ts +52 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/classes.authoritybroker.d.ts +20 -4
  5. package/dist_ts/classes.authoritybroker.js +44 -8
  6. package/dist_ts/classes.authoritycli.d.ts +4 -1
  7. package/dist_ts/classes.authoritycli.js +42 -7
  8. package/dist_ts/classes.authorityclient.d.ts +9 -1
  9. package/dist_ts/classes.authorityclient.js +9 -3
  10. package/dist_ts/classes.authoritydaemon.js +8 -5
  11. package/dist_ts/classes.authoritydatabase.d.ts +6 -2
  12. package/dist_ts/classes.authoritydatabase.js +13 -6
  13. package/dist_ts/classes.authorityimport.d.ts +4 -4
  14. package/dist_ts/classes.authorityimport.js +8 -7
  15. package/dist_ts/classes.authoritymodels.d.ts +11 -3
  16. package/dist_ts/classes.authoritymodels.js +43 -13
  17. package/dist_ts/classes.authoritypreuse.d.ts +8 -2
  18. package/dist_ts/classes.authoritypreuse.js +27 -17
  19. package/dist_ts/classes.authorityusage.d.ts +4 -3
  20. package/dist_ts/classes.authorityusage.js +3 -5
  21. package/dist_ts/classes.claudeauthority.d.ts +5 -0
  22. package/dist_ts/classes.claudeauthority.js +47 -4
  23. package/dist_ts/classes.claudecodelocks.d.ts +6 -0
  24. package/dist_ts/classes.claudecodelocks.js +30 -2
  25. package/dist_ts/classes.claudenative.d.ts +37 -2
  26. package/dist_ts/classes.claudenative.js +142 -13
  27. package/dist_ts/classes.cli.js +3 -5
  28. package/dist_ts/classes.codexpreuse.d.ts +14 -3
  29. package/dist_ts/classes.codexpreuse.js +127 -44
  30. package/dist_ts/classes.codexstatus.js +44 -95
  31. package/dist_ts/classes.login.d.ts +20 -10
  32. package/dist_ts/classes.login.js +14 -13
  33. package/dist_ts/classes.openaiprovider.d.ts +53 -0
  34. package/dist_ts/classes.openaiprovider.js +340 -0
  35. package/dist_ts/classes.opencodeharness.js +3 -2
  36. package/dist_ts/classes.service.js +3 -5
  37. package/dist_ts/index.d.ts +2 -0
  38. package/dist_ts/index.js +3 -1
  39. package/dist_ts/interfaces.openai.d.ts +118 -0
  40. package/dist_ts/interfaces.openai.js +3 -0
  41. package/dist_ts/openaiauth.d.ts +76 -0
  42. package/dist_ts/openaiauth.js +371 -0
  43. package/dist_ts/openaiusage.d.ts +59 -0
  44. package/dist_ts/openaiusage.js +222 -0
  45. package/dist_ts/plugins.d.ts +1 -3
  46. package/dist_ts/plugins.js +2 -4
  47. package/dist_ts/preuse.d.ts +50 -0
  48. package/dist_ts/preuse.js +63 -4
  49. package/dist_ts/ts_migration/legacysources/shared.js +4 -3
  50. package/openai-codex-license.txt +201 -0
  51. package/package.json +6 -4
  52. package/readme.md +102 -15
  53. package/third-party-notices.md +29 -0
  54. package/ts/00_commitinfo_data.ts +1 -1
  55. package/ts/authority-contract.ts +56 -8
  56. package/ts/classes.authoritybroker.ts +52 -13
  57. package/ts/classes.authoritycli.ts +48 -6
  58. package/ts/classes.authorityclient.ts +11 -3
  59. package/ts/classes.authoritydaemon.ts +7 -4
  60. package/ts/classes.authoritydatabase.ts +14 -6
  61. package/ts/classes.authorityimport.ts +12 -12
  62. package/ts/classes.authoritymodels.ts +42 -14
  63. package/ts/classes.authoritypreuse.ts +30 -17
  64. package/ts/classes.authorityusage.ts +8 -9
  65. package/ts/classes.claudeauthority.ts +37 -3
  66. package/ts/classes.claudecodelocks.ts +31 -1
  67. package/ts/classes.claudenative.ts +161 -14
  68. package/ts/classes.cli.ts +3 -3
  69. package/ts/classes.codexpreuse.ts +130 -40
  70. package/ts/classes.codexstatus.ts +40 -73
  71. package/ts/classes.login.ts +34 -21
  72. package/ts/classes.openaiprovider.ts +365 -0
  73. package/ts/classes.opencodeharness.ts +3 -2
  74. package/ts/classes.service.ts +3 -4
  75. package/ts/index.ts +2 -0
  76. package/ts/interfaces.openai.ts +142 -0
  77. package/ts/openaiauth.ts +422 -0
  78. package/ts/openaiusage.ts +247 -0
  79. package/ts/plugins.ts +1 -3
  80. package/ts/preuse.ts +88 -3
  81. package/ts/ts_migration/legacysources/shared.ts +4 -3
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import type { IOpenAiAccess } from './interfaces.openai.js';
2
3
  import type { IAuthSwitchPreuseOperation } from './authority-contract.js';
3
4
  import { AuthSwitchRefusal } from './authority-contract.js';
4
5
  import { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
@@ -6,10 +7,10 @@ import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
6
7
  import type { IStoredAuthorityPreuseOperation, TStoredAuthorityPreuseProblem } from './classes.authoritymodels.js';
7
8
  import { CodexPreuse, type ICodexPreuseLifecycle } from './classes.codexpreuse.js';
8
9
  import type { IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
9
- import { validatePreuseOptions } from './preuse.js';
10
+ import { PreuseFailureError, PreuseLimitError, validatePreuseOptions } from './preuse.js';
10
11
 
11
12
  export interface IAuthSwitchAuthorityPreuseExecutor {
12
- runAccess(credential: plugins.flexAccounts.IOpenAiChatGptAccessCredential,
13
+ runAccess(access: IOpenAiAccess,
13
14
  options: IHarnessPreuseOptions, lifecycle: ICodexPreuseLifecycle): Promise<IHarnessPreuseResult>;
14
15
  }
15
16
 
@@ -41,7 +42,8 @@ const hash = (value: unknown): value is string => typeof value === 'string' && /
41
42
  const publicOperation = (operation: IStoredAuthorityPreuseOperation): IAuthSwitchPreuseOperation => ({
42
43
  id: operation.id, accountId: operation.accountId, loginId: operation.grantId,
43
44
  purpose: operation.purpose, requestedModel: operation.requestedModel, model: operation.model,
44
- state: operation.state, result: operation.result, problem: operation.problem,
45
+ state: operation.state, result: operation.result, problem: operation.problem, limit: operation.limit ?? null,
46
+ failure: operation.failure ?? null,
45
47
  createdAt: operation.createdAt, updatedAt: operation.updatedAt,
46
48
  finishedAt: operation.finishedAt, revision: operation.revision,
47
49
  });
@@ -204,11 +206,8 @@ export class AuthSwitchAuthorityPreuse {
204
206
  const access = await this.broker.resolveUsageAccess(context, undefined, this.minAccessValidityMs);
205
207
  signal.throwIfAborted();
206
208
  phase = operation.requestedModel === null ? 'catalog' : 'access';
207
- const credential: plugins.flexAccounts.IOpenAiChatGptAccessCredential = {
208
- kind: 'chatgptAccess', providerId: 'openai', accessToken: access.accessToken,
209
- accountId: access.accountId, isFedrampAccount: access.isFedrampAccount,
210
- };
211
- const result = await this.executor.runAccess(credential,
209
+ const result = await this.executor.runAccess({ accessToken: access.accessToken,
210
+ accountId: access.accountId, isFedrampAccount: access.isFedrampAccount },
212
211
  { prompt, ...(operation.requestedModel === null ? {} : { model: operation.requestedModel }), signal },
213
212
  { beforeRequest: async model => {
214
213
  signal.throwIfAborted();
@@ -224,26 +223,40 @@ export class AuthSwitchAuthorityPreuse {
224
223
  state: 'complete', result: { inputTokens: count(result.inputTokens), outputTokens: count(result.outputTokens),
225
224
  totalTokens: count(result.totalTokens) }, problem: 'none', updatedAt: now, finishedAt: now,
226
225
  revision: prior.revision + 1, updateId }));
227
- } catch {
228
- await this.fail(operation.id, phase, signal);
226
+ } catch (error) {
227
+ await this.fail(operation.id, phase, signal,
228
+ error instanceof PreuseLimitError || error instanceof PreuseFailureError ? error : null);
229
229
  }
230
230
  }
231
231
 
232
- private async fail(operationId: string, phase: 'target' | 'access' | 'catalog', signal: AbortSignal): Promise<void> {
232
+ /**
233
+ * Settles a preuse that did not complete. A limit is named: the account's usage reading stopped it before
234
+ * anything was sent, or the provider refused the prompt -- before its answer began, which consumed nothing and
235
+ * settles `refused`, or during it, which stays `outcome_unknown`. Any other stated cause of a sent request is
236
+ * named as its `failure`.
237
+ */
238
+ private async fail(operationId: string, phase: 'target' | 'access' | 'catalog', signal: AbortSignal,
239
+ stated: PreuseLimitError | PreuseFailureError | null): Promise<void> {
233
240
  let current: IStoredAuthorityPreuseOperation;
234
241
  try { current = await this.readStored(operationId); }
235
242
  catch { return; }
236
243
  if (!['preparing', 'request_may_have_started'].includes(current.state)) return;
237
244
  const marked = current.state === 'request_may_have_started';
238
- const problem: TStoredAuthorityPreuseProblem = marked
239
- ? this.closed ? 'authority_closing' : signal.aborted ? 'cancelled' : 'request_uncertain'
240
- : this.closed ? 'authority_closing' : signal.aborted ? 'cancelled'
241
- : phase === 'target' ? 'target_unavailable' : phase === 'catalog' ? 'catalog_unavailable' : 'access_unavailable';
245
+ const stopped = this.closed ? 'authority_closing' : signal.aborted ? 'cancelled' : null;
246
+ const limit = stopped === null && stated instanceof PreuseLimitError && (stated.source === 'provider') === marked
247
+ ? stated : null;
248
+ const failure = stopped === null && marked && stated instanceof PreuseFailureError ? stated.failure : null;
249
+ const problem: TStoredAuthorityPreuseProblem = stopped ?? (limit ? limit.limit.kind
250
+ : marked ? 'request_uncertain'
251
+ : phase === 'target' ? 'target_unavailable' : phase === 'catalog' ? 'catalog_unavailable' : 'access_unavailable');
252
+ const state: IStoredAuthorityPreuseOperation['state'] = !marked ? 'not_sent'
253
+ : limit && !limit.generationStarted ? 'refused' : 'outcome_unknown';
242
254
  const updateId = plugins.crypto.randomUUID();
243
255
  const now = new Date(this.now()).toISOString();
244
256
  try {
245
- await this.database.changePreuseOperation(updateId, operationId, prior => ({ ...prior,
246
- state: marked ? 'outcome_unknown' : 'not_sent', problem,
257
+ await this.database.changePreuseOperation(updateId, operationId, prior => ({ ...prior, state, problem,
258
+ ...(limit ? { limit: { ...limit.limit } } : {}),
259
+ ...(failure ? { failure: { ...failure } } : {}),
247
260
  updatedAt: now, finishedAt: now, revision: prior.revision + 1, updateId }));
248
261
  } catch { /* A durable request marker remains recoverable as outcome_unknown on restart. */ }
249
262
  }
@@ -1,9 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
+ import type { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
3
+ import type { IOpenAiAccountRateLimits, IOpenAiRateLimitDetails, IOpenAiRateLimitWindow } from './interfaces.openai.js';
2
4
  import type { IClaudeLogin, IClaudeStatusRead } from './classes.claudestatus.js';
3
5
  import type { IHarnessAccountStatus } from './interfaces.harness.js';
4
6
 
5
- type TRateLimits = plugins.flexAccounts.ISmartAiProviderAccountRateLimits;
6
- type TAccessCredential = plugins.flexAccounts.IOpenAiChatGptAccessCredential;
7
+ type TRateLimits = IOpenAiAccountRateLimits;
7
8
 
8
9
  export type TAuthSwitchUsageProblem =
9
10
  | 'none' | 'unsupported' | 'access_unavailable' | 'provider_unavailable'
@@ -115,7 +116,7 @@ export interface IAuthSwitchAuthorityUsageOptions {
115
116
  action: (source: { context: IAuthSwitchUsageContext; login: IClaudeLogin;
116
117
  readContext(): Promise<IAuthSwitchUsageContext | null> }) => Promise<IAuthSwitchUsageSnapshot>): Promise<IAuthSwitchUsageSnapshot>;
117
118
  store: IAuthSwitchUsageStore;
118
- provider: Pick<plugins.flexAccounts.ISmartAiProviderAdapter, 'getAccountRateLimits'>;
119
+ provider: Pick<AuthSwitchOpenAiProvider, 'getAccountRateLimits'>;
119
120
  now?: () => number;
120
121
  currentMaxAgeMs?: number;
121
122
  maxStaleAgeMs?: number;
@@ -199,11 +200,11 @@ const publicClaudeStatus = (source: IHarnessAccountStatus): IAuthSwitchClaudeSta
199
200
 
200
201
  /** Allowlist the provider projection before it enters a management DTO or stored usage record. */
201
202
  const publicRateLimits = (source: TRateLimits): TRateLimits => {
202
- const window = (value: plugins.flexAccounts.ISmartAiProviderRateLimitWindow) => ({
203
+ const window = (value: IOpenAiRateLimitWindow) => ({
203
204
  usedPercent: value.usedPercent, limitWindowSeconds: value.limitWindowSeconds,
204
205
  resetAfterSeconds: value.resetAfterSeconds, resetsAt: value.resetsAt,
205
206
  });
206
- const detail = (value: plugins.flexAccounts.ISmartAiProviderRateLimitDetails) => ({
207
+ const detail = (value: IOpenAiRateLimitDetails) => ({
207
208
  allowed: value.allowed, limitReached: value.limitReached,
208
209
  ...(value.primaryWindow ? { primaryWindow: window(value.primaryWindow) } : {}),
209
210
  ...(value.secondaryWindow ? { secondaryWindow: window(value.secondaryWindow) } : {}),
@@ -511,10 +512,8 @@ export class AuthSwitchAuthorityUsage {
511
512
  }
512
513
 
513
514
  private query(access: IAuthSwitchUsageAccess): ReturnType<IAuthSwitchAuthorityUsageOptions['provider']['getAccountRateLimits']> {
514
- const credential: TAccessCredential = { kind: 'chatgptAccess', providerId: 'openai',
515
- accessToken: access.accessToken, accountId: access.accountId,
516
- isFedrampAccount: access.isFedrampAccount };
517
- return this.options.provider.getAccountRateLimits(credential, { timeoutMs: this.policy.providerTimeoutMs });
515
+ return this.options.provider.getAccountRateLimits({ accessToken: access.accessToken, accountId: access.accountId,
516
+ isFedrampAccount: access.isFedrampAccount }, { timeoutMs: this.policy.providerTimeoutMs });
518
517
  }
519
518
 
520
519
  /** Wait for admitted reads before the daemon disposes the shared provider or database. */
@@ -418,7 +418,13 @@ export class ClaudeNativeAuthority {
418
418
  return publicHandoff(result.handoff);
419
419
  }
420
420
 
421
- private async classify(operationId: string, session: IClaudeNativeLockedSession): Promise<IAuthSwitchClaudeNativeHandoff> {
421
+ /**
422
+ * Settles a handoff from what its native home holds. A pair that is exactly the prepared target commits only
423
+ * once Claude Code is proven to select it: by the switch that just wrote it (`targetProven`), or here, under
424
+ * this lease -- which is what a restarted daemon relies on for a write whose proof it never ran.
425
+ */
426
+ private async classify(operationId: string, session: IClaudeNativeLockedSession,
427
+ options: { targetProven?: boolean } = {}): Promise<IAuthSwitchClaudeNativeHandoff> {
422
428
  const context = await this.database.readClaudeHandoffContext(operationId);
423
429
  if (!context) throw new Error('Claude handoff operation is missing.');
424
430
  const handoff = context.handoff;
@@ -441,6 +447,14 @@ export class ClaudeNativeAuthority {
441
447
  if (handoff.after && samePair(observed, handoff.after)
442
448
  && sameIdentity(observed.identity, handoff.targetIdentity)
443
449
  && accessDigest === handoff.targetAccessDigest) {
450
+ if (!options.targetProven) {
451
+ try { await session.proveEffectiveLogin(await this.unsealLogin(context.incomingAccount, context.incoming)); }
452
+ catch (error) {
453
+ // The original bytes are not journaled, so an unproven write cannot be undone here; it is held apart.
454
+ return this.settle(operationId, 'quarantined', observed.runningEffectiveAuth, 'unsupported_effective_auth',
455
+ error instanceof ClaudeNativeEffectiveAuthUnsupportedError ? error.reason : null);
456
+ }
457
+ }
444
458
  return this.settle(operationId, 'committed', observed.runningEffectiveAuth);
445
459
  }
446
460
  return this.settle(operationId, 'quarantined', observed.runningEffectiveAuth, 'foreign_or_torn');
@@ -504,6 +518,14 @@ export class ClaudeNativeAuthority {
504
518
  if (!context || !sameIdentity(before.identity, context.handoff.sourceIdentity) || !before.login) {
505
519
  return this.classify(operationId, session);
506
520
  }
521
+ // Claude Code must be running on the outgoing login before anything is written, or nothing here says
522
+ // what the handoff replaces; nothing is written yet, so the reservation is simply undone.
523
+ try { await session.proveEffectiveLogin(before.login); }
524
+ catch (error) {
525
+ if (!(error instanceof ClaudeNativeEffectiveAuthUnsupportedError)) throw error;
526
+ return this.settle(operationId, 'aborted', before.runningEffectiveAuth, 'unsupported_effective_auth',
527
+ error.reason);
528
+ }
507
529
  let outgoingProof: Awaited<ReturnType<IClaudeNativeLockedSession['verifyProfileIdentity']>>;
508
530
  try { outgoingProof = await session.verifyProfileIdentity(before, context.handoff.sourceIdentity); }
509
531
  catch {
@@ -518,8 +540,20 @@ export class ClaudeNativeAuthority {
518
540
  await this.prepare(operationId, loginId, prepared.plan.before, prepared.plan.after,
519
541
  outgoingProof.accessTokenDigest, prepared.plan.profile.accessTokenDigest,
520
542
  sealedOutgoing, before.runningEffectiveAuth);
521
- session.writePreparedIfCurrent(prepared.plan);
522
- return this.classify(operationId, session);
543
+ const written = session.writePreparedIfCurrent(prepared.plan);
544
+ if (written.kind !== 'written') return this.classify(operationId, session);
545
+ // Claude Code must now select the incoming login. Whatever keeps that from being proven -- its answer,
546
+ // a lost lock, a failed read -- is a failed proof: the exact outgoing bytes go back while the locks are
547
+ // provably still held and the handoff is aborted; if they cannot, the home is quarantined.
548
+ try { await session.proveEffectiveLogin(incomingLogin); }
549
+ catch (error) {
550
+ const reason = error instanceof ClaudeNativeEffectiveAuthUnsupportedError ? error.reason : null;
551
+ let restored = false;
552
+ try { restored = session.restorePreparedSource(prepared.plan); } catch { restored = false; }
553
+ return this.settle(operationId, restored ? 'aborted' : 'quarantined', written.snapshot.runningEffectiveAuth,
554
+ 'unsupported_effective_auth', reason);
555
+ }
556
+ return this.classify(operationId, session, { targetProven: true });
523
557
  });
524
558
  } catch {
525
559
  const outcome = await this.reconcileOperation(operationId);
@@ -17,8 +17,18 @@ interface IClaudeCodeLock {
17
17
  interface IHeldLock {
18
18
  lockfilePath: string;
19
19
  release: () => Promise<void>;
20
+ /** After how long another process may take this lock over when its time is not renewed. */
21
+ stale: number;
22
+ /** The lock directory as it was created for this hold: a takeover removes it and creates another. */
23
+ identity: { device: number; inode: number; birthtimeMs: number };
20
24
  }
21
25
 
26
+ /**
27
+ * How much of a lock's stale time must remain when a lease is asserted. Everything a caller does after an
28
+ * assertion is a synchronous file write, so no other process can find the lock stale before it completes.
29
+ */
30
+ const HOLD_MARGIN_MS = 2_000;
31
+
22
32
  export interface IClaudeCodeLocksOptions {
23
33
  /** How long an operation waits for a lock that Claude Code holds. */
24
34
  timeoutMs?: number;
@@ -26,6 +36,12 @@ export interface IClaudeCodeLocksOptions {
26
36
 
27
37
  /** The vendor locks may be lost while an awaited database operation is in flight. */
28
38
  export interface IClaudeCodeLockLease {
39
+ /**
40
+ * Proves, at this moment, that every lock is still this hold's: its directory is the one this hold created,
41
+ * and its time was renewed recently enough that no other process may take it over before a synchronous write
42
+ * that follows completes. A lock whose renewal stalled -- an event loop blocked past its update interval --
43
+ * fails this even when nobody has taken it yet.
44
+ */
29
45
  assertHeld(): void;
30
46
  }
31
47
 
@@ -134,6 +150,16 @@ export class ClaudeCodeLocks {
134
150
  const lease: IClaudeCodeLockLease = {
135
151
  assertHeld: () => {
136
152
  if (!active || compromised) throw new ClaudeCodeLockOutcomeUnknownError(false);
153
+ for (const lock of held) {
154
+ let current: plugins.fs.Stats;
155
+ try { current = plugins.fs.lstatSync(lock.lockfilePath); }
156
+ catch { throw new ClaudeCodeLockOutcomeUnknownError(false); }
157
+ if (!current.isDirectory() || current.dev !== lock.identity.device || current.ino !== lock.identity.inode
158
+ || current.birthtimeMs !== lock.identity.birthtimeMs
159
+ || Date.now() - current.mtimeMs > lock.stale - HOLD_MARGIN_MS) {
160
+ throw new ClaudeCodeLockOutcomeUnknownError(false);
161
+ }
162
+ }
137
163
  },
138
164
  };
139
165
  const releaseAll = async (): Promise<void> => {
@@ -189,7 +215,11 @@ export class ClaudeCodeLocks {
189
215
  const release = await lockfileArg.lock(lock.file, {
190
216
  lockfilePath: lock.lockfilePath, realpath: false, stale: lock.stale, update: lock.update, onCompromised: onCompromisedArg,
191
217
  });
192
- heldArg.push({ lockfilePath: lock.lockfilePath, release });
218
+ let created: plugins.fs.Stats;
219
+ try { created = plugins.fs.lstatSync(lock.lockfilePath); }
220
+ catch (error) { await release().catch(() => undefined); throw error; }
221
+ heldArg.push({ lockfilePath: lock.lockfilePath, release, stale: lock.stale,
222
+ identity: { device: created.dev, inode: created.ino, birthtimeMs: created.birthtimeMs } });
193
223
  } catch (error) {
194
224
  if (errorCode(error) === 'ELOCKED') return lock;
195
225
  if (requireAllArg || !lock.bestEffort) {
@@ -6,9 +6,25 @@ import { claudeRequest } from './claudehttp.js';
6
6
  import { writeSecretFileAtomically } from './helpers.js';
7
7
  import { HarnessProcesses } from './classes.harnessprocesses.js';
8
8
 
9
- /** The private file and lock contract verified for this Claude Code release. */
10
- export const CLAUDE_NATIVE_HANDOFF_VERSION = '2.1.273';
9
+ /**
10
+ * The earliest Claude Code whose native file contract this adapter writes: `.credentials.json` beside
11
+ * `.claude.json`, and the lock protocol and live credential pickup verified for 2.1.273 (hints.md).
12
+ */
13
+ export const CLAUDE_NATIVE_MINIMUM_VERSION = '2.1.273';
14
+ /**
15
+ * The releases whose lock table, live credential pickup and refresh compare-and-swap were read from their own
16
+ * bundle (hints.md). A Claude Code session may keep running in the home a handoff writes only when it runs one
17
+ * of these; an installed release above the floor that is not listed is proven by `claude auth status` instead,
18
+ * and only while no session runs in that home.
19
+ */
20
+ export const CLAUDE_NATIVE_VERIFIED_RELEASES: readonly string[] = ['2.1.273', '2.1.282'];
21
+ /**
22
+ * Token files Claude Code reads at fixed paths when no descriptor variable names one (2.1.282 `KI`, `YGn`):
23
+ * the remote container's hand-off of an OAuth token or an API key. Either one outranks the native login.
24
+ */
25
+ const REMOTE_CREDENTIAL_FILES = ['/home/claude/.claude/remote/.oauth_token', '/home/claude/.claude/remote/.api_key'] as const;
11
26
  const MAX_NATIVE_FILE_BYTES = 4 * 1024 * 1024;
27
+ const AUTH_STATUS_TIMEOUT_MS = 4_000;
12
28
  const MAX_PROCESS_CONTEXT_BYTES = 1024 * 1024;
13
29
  const PROFILE_TIMEOUT_MS = 10_000;
14
30
  const ACCOUNT_CACHES = [
@@ -19,7 +35,8 @@ const ACCOUNT_CACHES = [
19
35
  const CREDENTIAL_OVERRIDES = [
20
36
  'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'ANTHROPIC_API_KEY',
21
37
  'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_PROFILE', 'CLAUDE_CODE_USE_BEDROCK',
22
- 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_BASE_URL',
38
+ 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
39
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_BASE_URL',
23
40
  'ANTHROPIC_FEDERATION_RULE_ID', 'ANTHROPIC_ORGANIZATION_ID',
24
41
  'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_IDENTITY_TOKEN', 'ANTHROPIC_WORKSPACE_ID',
25
42
  'ANTHROPIC_UNIX_SOCKET', 'CLAUDE_CODE_REMOTE', 'CLAUDE_CODE_SIMPLE',
@@ -37,8 +54,9 @@ export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
37
54
 
38
55
  constructor(public readonly reason: TAuthSwitchClaudeProofFailure) {
39
56
  super(reason === 'unsupported_release'
40
- ? `Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`
41
- : `Cannot prove that Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} uses its native claude.ai login (${reason}).`);
57
+ ? `Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_MINIMUM_VERSION} or newer, and a session `
58
+ + `running in this home must run a verified release (${CLAUDE_NATIVE_VERIFIED_RELEASES.join(', ')}).`
59
+ : `Cannot prove that Claude Code uses its native claude.ai login (${reason}).`);
42
60
  }
43
61
  }
44
62
 
@@ -95,6 +113,20 @@ export type TClaudeNativePrepareResult =
95
113
 
96
114
  export interface IClaudeNativeLockedSession {
97
115
  readStable(): IClaudeNativeSnapshot;
116
+ /**
117
+ * Proves that Claude Code itself selects the native login of `expected` in this home. A verified release is
118
+ * proven by the adapter's own reading of its selector; any other release above the floor is asked through
119
+ * `claude auth status --json`, which names the organization and the account's email, the only account
120
+ * identifiers it reports. Throws `ClaudeNativeEffectiveAuthUnsupportedError` when it cannot be proven, and
121
+ * `ClaudeCodeLockOutcomeUnknownError` when the locks were lost while it asked.
122
+ */
123
+ proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void>;
124
+ /**
125
+ * Writes back the exact bytes a written plan replaced, while the locks that guarded the write are still held.
126
+ * True once both files read back as they were; false, writing nothing, when the pair is no longer exactly
127
+ * what the plan wrote.
128
+ */
129
+ restorePreparedSource(plan: IClaudeNativePreparedWrite): boolean;
98
130
  verifyProfileIdentity(snapshot: IClaudeNativeSnapshot,
99
131
  expected: IClaudeNativeIdentity): Promise<IClaudeNativeProfileProof>;
100
132
  prepareWrite(input: {
@@ -115,6 +147,12 @@ export interface IClaudeNativeLockedSession {
115
147
  export interface IClaudeNativeVersionProbe {
116
148
  installedVersion(): string;
117
149
  runningContexts(): readonly IClaudeNativeRunningContext[];
150
+ /**
151
+ * What `claude auth status --json` prints under `env`, whatever its exit code, or null when it does not
152
+ * answer. Asynchronous on purpose: it runs while the vendor locks are held, and their renewal timers must keep
153
+ * running.
154
+ */
155
+ authStatus(env: NodeJS.ProcessEnv): Promise<string | null>;
118
156
  }
119
157
 
120
158
  /** Only selector keys and paths, never a process's credential values, leave the /proc reader. */
@@ -171,8 +209,30 @@ const onlyDigests = (value: IClaudeNativeDigests): IClaudeNativeDigests => ({
171
209
  });
172
210
  const preparedWrites = new WeakMap<IClaudeNativePreparedWrite, {
173
211
  session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
174
- originalConfigRaw: Buffer | null; targetAccessToken: string;
212
+ originalCredentialRaw: Buffer | null; originalConfigRaw: Buffer | null; targetAccessToken: string;
175
213
  }>();
214
+ /** A written plan's original bytes, kept until the handoff proves the write or restores them. */
215
+ const writtenPlans = new WeakMap<IClaudeNativePreparedWrite, {
216
+ session: ClaudeNativeLockedSession; credentialRaw: string; configRaw: string;
217
+ originalCredentialRaw: Buffer; originalConfigRaw: Buffer;
218
+ }>();
219
+
220
+ const parseClaudeVersion = (value: string): [number, number, number] | null => {
221
+ const match = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/.exec(value);
222
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
223
+ };
224
+ const atLeast = (version: [number, number, number], minimum: [number, number, number]): boolean => {
225
+ for (let index = 0; index < 3; index++) {
226
+ if (version[index] !== minimum[index]) return version[index] > minimum[index];
227
+ }
228
+ return true;
229
+ };
230
+
231
+ /** Which proof applies to one operation: the adapter's own selector reading, or Claude Code's own answer. */
232
+ interface IClaudeNativeSupport {
233
+ runningSessions: number;
234
+ proof: 'verified_release' | 'auth_status';
235
+ }
176
236
  const sameIdentity = (left: IClaudeNativeIdentity, right: IClaudeNativeIdentity): boolean =>
177
237
  left.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
178
238
 
@@ -303,6 +363,15 @@ const assertNoKnownNativeLoginOverride = (env: NodeJS.ProcessEnv, processRoot?:
303
363
  for (const key of CREDENTIAL_OVERRIDES) {
304
364
  if (env[key]) throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
305
365
  }
366
+ for (const file of REMOTE_CREDENTIAL_FILES) {
367
+ try {
368
+ plugins.fs.lstatSync(processViewPath(file, processRoot));
369
+ } catch (error) {
370
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
371
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
372
+ }
373
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
374
+ }
306
375
  const configDir = env.ANTHROPIC_CONFIG_DIR?.trim()
307
376
  || (env.XDG_CONFIG_HOME?.trim()
308
377
  ? plugins.path.join(env.XDG_CONFIG_HOME.trim(), 'anthropic')
@@ -504,6 +573,20 @@ class ClaudeNativeVersionProbe implements IClaudeNativeVersionProbe {
504
573
  } catch { return 'unknown'; }
505
574
  }
506
575
 
576
+ public authStatus(env: NodeJS.ProcessEnv): Promise<string | null> {
577
+ // Well inside the shortest lock renewal window (the config lock renews every 5 s); it answers in ~200 ms.
578
+ return new Promise(resolve => {
579
+ const child = plugins.childProcess.execFile(this.executable, ['auth', 'status', '--json'], {
580
+ encoding: 'utf8', timeout: AUTH_STATUS_TIMEOUT_MS, maxBuffer: 64 * 1024, windowsHide: true, env,
581
+ }, (error, stdout) => {
582
+ // A logged-out status still prints its JSON and exits 1; only no answer at all is null.
583
+ const exited = error === null || typeof error.code === 'number';
584
+ resolve(exited && stdout ? stdout : null);
585
+ });
586
+ child.stdin?.end();
587
+ });
588
+ }
589
+
507
590
  public runningContexts(): readonly IClaudeNativeRunningContext[] {
508
591
  const contexts: IClaudeNativeRunningContext[] = [];
509
592
  // Keep candidates from every mount namespace: a bind mount can expose the same inode.
@@ -597,7 +680,7 @@ export class ClaudeNativeAdapter {
597
680
  this.platform = options.platform ?? process.platform;
598
681
  }
599
682
 
600
- private assertSupported(): number {
683
+ private assertSupported(): IClaudeNativeSupport {
601
684
  if (this.platform !== 'linux') throw new Error('Native Claude handoff requires the verified Linux file backend.');
602
685
  const home = plugins.fs.lstatSync(this.options.configDir);
603
686
  if (!home.isDirectory() || home.isSymbolicLink() || home.uid !== process.getuid?.()
@@ -621,9 +704,12 @@ export class ClaudeNativeAdapter {
621
704
  for (const file of this.settingsFiles) {
622
705
  assertSettingsSelectNative(file);
623
706
  }
624
- if (this.versionProbe.installedVersion() !== CLAUDE_NATIVE_HANDOFF_VERSION) {
707
+ const installedText = this.versionProbe.installedVersion();
708
+ const installed = parseClaudeVersion(installedText);
709
+ if (!installed || !atLeast(installed, parseClaudeVersion(CLAUDE_NATIVE_MINIMUM_VERSION)!)) {
625
710
  throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
626
711
  }
712
+ const installedVerified = CLAUDE_NATIVE_VERIFIED_RELEASES.includes(installedText);
627
713
  let runningSessions = 0;
628
714
  const nativeHomeIdentity = directoryIdentity(this.options.configDir);
629
715
  const configParentIdentity = directoryIdentity(configParent);
@@ -636,7 +722,9 @@ export class ClaudeNativeAdapter {
636
722
  || !sameDirectory(running.configParentIdentity, configParentIdentity)) {
637
723
  throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
638
724
  }
639
- if (running.version !== CLAUDE_NATIVE_HANDOFF_VERSION) {
725
+ // A session keeps taking its own locks and picking up the swapped login while the handoff runs, so
726
+ // it must be a release whose lock and pickup contract was read from its bundle.
727
+ if (running.version === null || !CLAUDE_NATIVE_VERIFIED_RELEASES.includes(running.version)) {
640
728
  throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
641
729
  }
642
730
  if (!running.env || !running.cwd || running.hasSettingsOverride === null
@@ -650,7 +738,34 @@ export class ClaudeNativeAdapter {
650
738
  assertProjectSettingsSelectNative(running.cwd, running.processRoot);
651
739
  runningSessions++;
652
740
  }
653
- return runningSessions;
741
+ // An installed release nobody verified is proven by its own answer, which says nothing about how a session
742
+ // already running would take the locks or pick up the swap; it is written only while none runs here.
743
+ if (!installedVerified && runningSessions > 0) {
744
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
745
+ }
746
+ return { runningSessions, proof: installedVerified ? 'verified_release' : 'auth_status' };
747
+ }
748
+
749
+ /**
750
+ * Claude Code's own account of the login it selects in this home: `claude auth status --json` under the
751
+ * adapter's environment. It must be the claude.ai login of `expected`, read from this home, with no API key.
752
+ */
753
+ private async proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void> {
754
+ if (this.assertSupported().proof === 'verified_release') return;
755
+ const organizationUuid = expected.oauthAccount.organizationUuid;
756
+ const email = typeof expected.oauthAccount.emailAddress === 'string' ? expected.oauthAccount.emailAddress : null;
757
+ const raw = await this.versionProbe.authStatus(this.env);
758
+ let status: unknown;
759
+ try { status = raw === null ? null : JSON.parse(raw); } catch { status = null; }
760
+ if (!isRecord(status) || typeof status.loggedIn !== 'boolean' || typeof status.authMethod !== 'string') {
761
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
762
+ }
763
+ if (status.loggedIn !== true || status.authMethod !== 'claude.ai' || status.apiProvider !== 'firstParty'
764
+ || (status.apiKeySource !== undefined && status.apiKeySource !== null)
765
+ || status.configDirectory !== this.options.configDir || status.orgId !== organizationUuid
766
+ || (status.email ?? null) !== email) {
767
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('override');
768
+ }
654
769
  }
655
770
 
656
771
  /** The caller keeps its NoSQLDB journal and both ownership transactions inside this callback. */
@@ -659,12 +774,13 @@ export class ClaudeNativeAdapter {
659
774
  return this.locks.holdAsync(async lease => {
660
775
  this.assertSupported();
661
776
  const session = new ClaudeNativeLockedSession(this.credentialFile, this.options.configFile,
662
- this.fetcher, lease, () => this.assertSupported());
777
+ this.fetcher, lease, () => this.assertSupported().runningSessions,
778
+ expected => this.proveEffectiveLogin(expected));
663
779
  const firstSnapshot = session.readStable();
664
780
  const result = await action(session);
665
781
  try {
666
782
  lease.assertHeld();
667
- const afterRunningSessions = this.assertSupported();
783
+ const afterRunningSessions = this.assertSupported().runningSessions;
668
784
  if (firstSnapshot.runningEffectiveAuth === 'no_scoped_sessions' && afterRunningSessions > 0) {
669
785
  throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
670
786
  }
@@ -680,7 +796,34 @@ export class ClaudeNativeAdapter {
680
796
  class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
681
797
  constructor(private readonly credentialFile: string, private readonly configFile: string,
682
798
  private readonly fetcher: typeof fetch, private readonly lease: IClaudeCodeLockLease,
683
- private readonly assertSupported: () => number) {}
799
+ private readonly assertSupported: () => number,
800
+ private readonly proveEffective: (expected: IClaudeNativeLogin) => Promise<void>) {}
801
+
802
+ public async proveEffectiveLogin(expected: IClaudeNativeLogin): Promise<void> {
803
+ this.lease.assertHeld();
804
+ await this.proveEffective(expected);
805
+ // Asserted after the answer, before anything is decided or written on it.
806
+ this.lease.assertHeld();
807
+ }
808
+
809
+ public restorePreparedSource(plan: IClaudeNativePreparedWrite): boolean {
810
+ this.lease.assertHeld();
811
+ const written = writtenPlans.get(plan);
812
+ if (!written || written.session !== this) throw new Error('Claude native write plan is not held by this lease.');
813
+ writtenPlans.delete(plan);
814
+ let current: ReturnType<typeof readPair>;
815
+ try { current = readPair(this.credentialFile, this.configFile); } catch { return false; }
816
+ if (!sameRaw(current.credentialRaw, Buffer.from(written.credentialRaw))
817
+ || !sameRaw(current.configRaw, Buffer.from(written.configRaw))) return false;
818
+ this.lease.assertHeld();
819
+ writeSecretFileAtomically(this.credentialFile, written.originalCredentialRaw.toString('utf8'));
820
+ writeSecretFileAtomically(this.configFile, written.originalConfigRaw.toString('utf8'));
821
+ this.lease.assertHeld();
822
+ const restored = readPair(this.credentialFile, this.configFile);
823
+ return sameRaw(restored.credentialRaw, written.originalCredentialRaw)
824
+ && sameRaw(restored.configRaw, written.originalConfigRaw)
825
+ && sameDigests(digests(restored), plan.before);
826
+ }
684
827
 
685
828
  private observation(): IClaudeNativeDigests | null {
686
829
  try { return digests(readPair(this.credentialFile, this.configFile)); }
@@ -811,7 +954,7 @@ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
811
954
  targetIdentity: Object.freeze({ ...input.expectedIdentity }),
812
955
  profile: Object.freeze({ ...profile }),
813
956
  });
814
- preparedWrites.set(plan, { session: this, credentialRaw, configRaw,
957
+ preparedWrites.set(plan, { session: this, credentialRaw, configRaw, originalCredentialRaw: original.credentialRaw,
815
958
  originalConfigRaw: original.configRaw, targetAccessToken: String(target.login.claudeAiOauth.accessToken) });
816
959
  return { kind: 'prepared', plan };
817
960
  }
@@ -864,6 +1007,10 @@ class ClaudeNativeLockedSession implements IClaudeNativeLockedSession {
864
1007
  || plan.profile.accessTokenDigest !== hash(String(snapshot.login.claudeAiOauth.accessToken))) {
865
1008
  return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: onlyDigests(snapshot) };
866
1009
  }
1010
+ if (prepared.originalCredentialRaw !== null && prepared.originalConfigRaw !== null) {
1011
+ writtenPlans.set(plan, { session: this, credentialRaw: prepared.credentialRaw, configRaw: prepared.configRaw,
1012
+ originalCredentialRaw: prepared.originalCredentialRaw, originalConfigRaw: prepared.originalConfigRaw });
1013
+ }
867
1014
  return { kind: 'written', snapshot, profile: plan.profile };
868
1015
  } catch {
869
1016
  return { kind: 'uncertain', reason: 'native_write_or_readback_failed', observed: this.observation() };
package/ts/classes.cli.ts CHANGED
@@ -372,11 +372,11 @@ ${bold('Environment')}
372
372
  const cancel = () => { cancelled = true; void handle?.cancel().catch(() => undefined); };
373
373
  process.on('SIGINT', cancel); process.on('SIGTERM', cancel);
374
374
  try {
375
- handle = await harness.beginLogin({ providerId: provider.providerId, flow: provider.flows.includes('device') ? 'device' : provider.flows[0] });
375
+ handle = await harness.beginLogin({ providerId: provider.providerId, flow: 'device' });
376
376
  if (cancelled) await handle.cancel();
377
- else if (handle.prompt.flow === 'device') {
377
+ else {
378
378
  process.stdout.write(`Open ${plainText(handle.prompt.verificationUrl)}\nCode: ${plainText(handle.prompt.userCode)}\nWaiting for login. Press Ctrl+C to cancel.\n`);
379
- } else process.stdout.write(`Open ${plainText(handle.prompt.authUrl)}\nWaiting for login. Press Ctrl+C to cancel.\n`);
379
+ }
380
380
  return this.reportOutcome((await handle.completion).outcome);
381
381
  } finally {
382
382
  process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel);