@modelprofile.com/authswitch 8.0.0 → 8.2.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 (61) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +56 -0
  3. package/dist_ts/authority-contract.js +54 -2
  4. package/dist_ts/authority-import-contract.d.ts +211 -0
  5. package/dist_ts/authority-import-contract.js +17 -0
  6. package/dist_ts/classes.authoritybroker.js +3 -2
  7. package/dist_ts/classes.authoritycli.d.ts +4 -0
  8. package/dist_ts/classes.authoritycli.js +148 -11
  9. package/dist_ts/classes.authorityclient.d.ts +24 -11
  10. package/dist_ts/classes.authorityclient.js +40 -22
  11. package/dist_ts/classes.authoritydaemon.d.ts +12 -0
  12. package/dist_ts/classes.authoritydaemon.js +123 -36
  13. package/dist_ts/classes.authoritydatabase.d.ts +14 -1
  14. package/dist_ts/classes.authoritydatabase.js +48 -16
  15. package/dist_ts/classes.authorityimport.d.ts +176 -0
  16. package/dist_ts/classes.authorityimport.js +664 -0
  17. package/dist_ts/classes.authoritymodels.d.ts +16 -1
  18. package/dist_ts/classes.authoritymodels.js +31 -3
  19. package/dist_ts/classes.authorityservice.d.ts +10 -0
  20. package/dist_ts/classes.authorityservice.js +24 -1
  21. package/dist_ts/classes.claudeauthority.js +19 -11
  22. package/dist_ts/classes.claudenative.d.ts +63 -3
  23. package/dist_ts/classes.claudenative.js +68 -8
  24. package/dist_ts/index.d.ts +1 -0
  25. package/dist_ts/index.js +6 -3
  26. package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
  27. package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
  28. package/dist_ts/ts_migration/index.js +3 -1
  29. package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
  30. package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
  31. package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
  32. package/dist_ts/ts_migration/legacysources/index.js +24 -0
  33. package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
  34. package/dist_ts/ts_migration/legacysources/material.js +120 -0
  35. package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
  36. package/dist_ts/ts_migration/legacysources/nativestores.js +214 -0
  37. package/dist_ts/ts_migration/legacysources/shared.d.ts +125 -0
  38. package/dist_ts/ts_migration/legacysources/shared.js +140 -0
  39. package/package.json +9 -1
  40. package/readme.md +111 -2
  41. package/ts/00_commitinfo_data.ts +1 -1
  42. package/ts/authority-contract.ts +106 -0
  43. package/ts/authority-import-contract.ts +217 -0
  44. package/ts/classes.authoritybroker.ts +2 -1
  45. package/ts/classes.authoritycli.ts +164 -10
  46. package/ts/classes.authorityclient.ts +65 -21
  47. package/ts/classes.authoritydaemon.ts +121 -34
  48. package/ts/classes.authoritydatabase.ts +49 -15
  49. package/ts/classes.authorityimport.ts +746 -0
  50. package/ts/classes.authoritymodels.ts +32 -2
  51. package/ts/classes.authorityservice.ts +27 -0
  52. package/ts/classes.claudeauthority.ts +22 -10
  53. package/ts/classes.claudenative.ts +104 -10
  54. package/ts/index.ts +5 -2
  55. package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
  56. package/ts/ts_migration/index.ts +2 -0
  57. package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
  58. package/ts/ts_migration/legacysources/index.ts +35 -0
  59. package/ts/ts_migration/legacysources/material.ts +134 -0
  60. package/ts/ts_migration/legacysources/nativestores.ts +212 -0
  61. package/ts/ts_migration/legacysources/shared.ts +218 -0
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import type { TAuthSwitchLoginPrompt } from './authority-contract.js';
2
+ import type { TAuthSwitchClaudeProofFailure, TAuthSwitchLoginPrompt } from './authority-contract.js';
3
3
  import type { IAuthSwitchStoredUsage } from './classes.authorityusage.js';
4
4
 
5
5
  export interface IStoredAuthorityMeta {
@@ -51,6 +51,27 @@ export interface IStoredAuthorityGrant {
51
51
  updateId: string;
52
52
  }
53
53
 
54
+ /**
55
+ * Whether a change of grant owner hands the refresher from one live owner to another.
56
+ *
57
+ * `daemon`, `legacy_native` and `claude_native` each refresh the grant they own, so moving between any two
58
+ * of them is a transfer and needs a verified handoff: doing it in one write would leave two owners believing
59
+ * they may send the same rotating refresh token. `none` is not an owner but the absence of one -- a grant
60
+ * that needs re-authentication has no refresher, and a device login gives it one -- so every transition to
61
+ * or from `none` is an ordinary lifecycle step, not a transfer.
62
+ */
63
+ export const transfersGrantRefresher = (previous: IStoredAuthorityGrant['owner'],
64
+ next: IStoredAuthorityGrant['owner']): boolean =>
65
+ previous !== next && previous !== 'none' && next !== 'none';
66
+
67
+ /** The refusal that names the direction of a transfer, so a caller learns which handoff it needs. */
68
+ export const grantRefresherTransferRefusal = (previous: IStoredAuthorityGrant['owner'],
69
+ next: IStoredAuthorityGrant['owner']): string => next === 'daemon'
70
+ ? 'Native ownership requires a verified handoff.'
71
+ : previous === 'daemon'
72
+ ? 'Daemon ownership is released by a verified handoff.'
73
+ : 'Native ownership moves between native owners by a verified handoff.';
74
+
54
75
  /** Credential-free provider usage cached for one exact account/login pair. */
55
76
  export interface IStoredAuthorityUsage extends IAuthSwitchStoredUsage {
56
77
  id: string;
@@ -134,6 +155,8 @@ export interface IStoredAuthorityClaudeHandoff {
134
155
  sealedOutgoing: string | null;
135
156
  runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth' | null;
136
157
  problem: 'none' | 'native_uncertain' | 'foreign_or_torn' | 'unsupported_effective_auth' | 'database_uncertain';
158
+ /** Which condition of the native-login proof failed; stored, so a lost answer stays diagnosable. */
159
+ proofFailure: TAuthSwitchClaudeProofFailure | null;
137
160
  startedAt: string;
138
161
  updatedAt: string;
139
162
  revision: number;
@@ -481,7 +504,7 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
481
504
  if (!object(value) || !exactKeys(value, ['id', 'homeId', 'outgoingAccountId', 'outgoingGrantId',
482
505
  'incomingAccountId', 'incomingGrantId', 'phase', 'before', 'after', 'sourceIdentity',
483
506
  'targetIdentity', 'sourceAccessDigest', 'targetAccessDigest', 'sealedOutgoing',
484
- 'runningEffectiveAuth', 'problem', 'startedAt', 'updatedAt', 'revision', 'updateId'])
507
+ 'runningEffectiveAuth', 'problem', 'proofFailure', 'startedAt', 'updatedAt', 'revision', 'updateId'])
485
508
  || !uuid(value.id) || !hash(value.homeId) || !hash(value.outgoingAccountId)
486
509
  || !hash(value.outgoingGrantId) || !hash(value.incomingAccountId) || !hash(value.incomingGrantId)
487
510
  || value.outgoingAccountId === value.incomingAccountId || value.outgoingGrantId === value.incomingGrantId
@@ -493,8 +516,14 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
493
516
  || !ciphertext(value.sealedOutgoing)
494
517
  || ![null, 'no_scoped_sessions', 'unsupported_effective_auth'].includes(value.runningEffectiveAuth as null)
495
518
  || !['none', 'native_uncertain', 'foreign_or_torn', 'unsupported_effective_auth', 'database_uncertain'].includes(String(value.problem))
519
+ || ![null, 'unsupported_release', 'override', 'profile', 'profile_unreadable', 'settings',
520
+ 'subscription', 'running_session'].includes(value.proofFailure as null)
496
521
  || !iso(value.startedAt) || !iso(value.updatedAt) || !revision(value.revision)
497
522
  || value.revision === 0 || !uuid(value.updateId)) throw new Error('Invalid Claude native handoff.');
523
+ // The detail belongs to exactly the problem it explains, so a client can trust one without the other.
524
+ if (value.proofFailure !== null && value.problem !== 'unsupported_effective_auth') {
525
+ throw new Error('Claude handoff names a proof failure without the problem it explains.');
526
+ }
498
527
  const hasProof = value.before !== null && value.after !== null
499
528
  && value.sourceAccessDigest !== null && value.targetAccessDigest !== null;
500
529
  const hasNoProof = value.before === null && value.after === null
@@ -817,6 +846,7 @@ export class AuthSwitchAuthorityClaudeHandoffModel extends plugins.nosqldb.Smart
817
846
  @plugins.nosqldb.svDb() public sealedOutgoing!: string | null;
818
847
  @plugins.nosqldb.svDb() public runningEffectiveAuth!: IStoredAuthorityClaudeHandoff['runningEffectiveAuth'];
819
848
  @plugins.nosqldb.svDb() public problem!: IStoredAuthorityClaudeHandoff['problem'];
849
+ @plugins.nosqldb.svDb() public proofFailure!: IStoredAuthorityClaudeHandoff['proofFailure'];
820
850
  @plugins.nosqldb.svDb() public startedAt!: string;
821
851
  @plugins.nosqldb.svDb() public updatedAt!: string;
822
852
  @plugins.nosqldb.svDb() public revision!: number;
@@ -1,5 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
3
+ import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
4
+ import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
5
+ resolveClaudeNativeHome } from './classes.claudenative.js';
3
6
 
4
7
  export interface IAuthSwitchAuthorityPaths {
5
8
  runtimeDirectory: string;
@@ -60,12 +63,36 @@ export class AuthSwitchAuthorityService {
60
63
  public stop(): Promise<plugins.smartdaemon.ISystemdUnitState> { return this.unit.stop(); }
61
64
  }
62
65
 
66
+ /**
67
+ * The Claude native authority this user's daemon serves.
68
+ *
69
+ * It is wired whether or not Claude Code is installed: nothing here touches the filesystem, and every
70
+ * operation that needs the native home refuses until a verified import receipt has registered it. An
71
+ * environment that names a home this daemon cannot resolve is a named startup failure rather than a
72
+ * silently missing capability, because the same environment also decides which file the import reads.
73
+ */
74
+ export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv = process.env,
75
+ homeDirectory: string = plugins.os.homedir()): IClaudeNativeAuthorityOptions => {
76
+ const resolution = resolveClaudeNativeHome(env, homeDirectory);
77
+ if (resolution.kind === 'unusable') {
78
+ throw new Error(`Authswitch authority cannot locate Claude Code's native home: ${resolution.defect.problem}.`);
79
+ }
80
+ const { configDir, configFile, homeId } = resolution.home;
81
+ // The adapter re-derives which home Claude Code itself selects; it must read the environment this
82
+ // home was resolved from, or the two could disagree about the same host. Its settings sources are
83
+ // stated from that same home: this daemon's working directory is the service unit's own (the package
84
+ // root, see `AuthSwitchAuthorityService`), so a project settings file beside it selects nothing.
85
+ return { homeId, adapter: new ClaudeNativeAdapter({ configDir, configFile, env,
86
+ settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
87
+ };
88
+
63
89
  export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
64
90
  const daemon = new AuthSwitchAuthorityDaemon({
65
91
  dataDirectory: paths.dataDirectory,
66
92
  socketPath: paths.databaseSocketPath,
67
93
  authoritySocketPath: paths.authoritySocketPath,
68
94
  runtimeSocketPath: paths.runtimeSocketPath,
95
+ claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
69
96
  });
70
97
  await daemon.start();
71
98
  await new Promise<void>((resolve, reject) => {
@@ -1,9 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchClaudeNativeHandoff } from './authority-contract.js';
3
+ import { AuthSwitchRefusal } from './authority-contract.js';
3
4
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
4
5
  import type { IStoredAuthorityAccount, IStoredAuthorityClaudeHandoff, IStoredAuthorityClaudeHome,
5
6
  IStoredAuthorityGrant, IStoredAuthorityNativePair } from './classes.authoritymodels.js';
6
- import { ClaudeNativeAdapter, type IClaudeNativeIdentity, type IClaudeNativeLogin,
7
+ import { ClaudeNativeAdapter, ClaudeNativeEffectiveAuthUnsupportedError,
8
+ type IClaudeNativeIdentity, type IClaudeNativeLogin,
7
9
  type IClaudeNativeLockedSession, type IClaudeNativeSnapshot } from './classes.claudenative.js';
8
10
  import { ClaudeTokenRefresh } from './classes.claudetokenrefresh.js';
9
11
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
@@ -34,6 +36,7 @@ const publicHandoff = (handoff: IStoredAuthorityClaudeHandoff): IAuthSwitchClaud
34
36
  id: handoff.id, homeId: handoff.homeId, outgoingAccountId: handoff.outgoingAccountId,
35
37
  incomingAccountId: handoff.incomingAccountId, phase: handoff.phase, problem: handoff.problem,
36
38
  runningEffectiveAuth: handoff.runningEffectiveAuth, updatedAt: handoff.updatedAt,
39
+ ...(handoff.proofFailure === null ? {} : { proofFailure: handoff.proofFailure }),
37
40
  });
38
41
 
39
42
  /** Daemon-owned Claude refresh and a journaled transfer of one native home between two grants. */
@@ -137,7 +140,7 @@ export class ClaudeNativeAuthority {
137
140
  const account = await this.database.readAccount(grant.accountId);
138
141
  if (!account || account.removed) return;
139
142
  if (!(await this.database.hasVerifiedClaudeGrantReceipt(account.id, grant.id))) {
140
- throw new Error('Claude refresh requires a verified import receipt.');
143
+ throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude refresh requires a verified import receipt.');
141
144
  }
142
145
  const login = await this.unsealLogin(account, grant);
143
146
  if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
@@ -229,7 +232,7 @@ export class ClaudeNativeAuthority {
229
232
  throw new Error('Inactive Claude status authority changed.');
230
233
  }
231
234
  if (!(await this.database.hasVerifiedClaudeGrantReceipt(view.account.id, view.grant.id))) {
232
- throw new Error('Claude status requires a verified import receipt.');
235
+ throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude status requires a verified import receipt.');
233
236
  }
234
237
  const currentLogin = this.statusLogin(view.account, await this.unsealLogin(view.account, view.grant));
235
238
  if (!currentLogin.scopes.includes('user:profile')) {
@@ -342,7 +345,7 @@ export class ClaudeNativeAuthority {
342
345
  incomingAccountId: incoming.accountId, incomingGrantId: incoming.id, phase: 'reserved',
343
346
  before: null, after: null, sourceIdentity: identityFor(outgoingAccount),
344
347
  targetIdentity: identityFor(incomingAccount), sourceAccessDigest: null, targetAccessDigest: null,
345
- sealedOutgoing: null, runningEffectiveAuth: null, problem: 'none', startedAt: now,
348
+ sealedOutgoing: null, runningEffectiveAuth: null, problem: 'none', proofFailure: null, startedAt: now,
346
349
  updatedAt: now, revision: 1, updateId,
347
350
  };
348
351
  return { home: this.homeStep(home, updateId, { pendingOperationId: operationId }), handoff,
@@ -369,7 +372,8 @@ export class ClaudeNativeAuthority {
369
372
 
370
373
  private async settle(operationId: string, phase: 'committed' | 'aborted' | 'quarantined',
371
374
  runningEffectiveAuth: IClaudeNativeSnapshot['runningEffectiveAuth'] | null,
372
- problem: IStoredAuthorityClaudeHandoff['problem'] = 'none'): Promise<IAuthSwitchClaudeNativeHandoff> {
375
+ problem: IStoredAuthorityClaudeHandoff['problem'] = 'none',
376
+ proofFailure: IStoredAuthorityClaudeHandoff['proofFailure'] = null): Promise<IAuthSwitchClaudeNativeHandoff> {
373
377
  const context = await this.database.readClaudeHandoffContext(operationId);
374
378
  if (!context) throw new Error('Claude handoff operation is missing.');
375
379
  if (['committed', 'aborted', 'quarantined'].includes(context.handoff.phase)) return publicHandoff(context.handoff);
@@ -395,7 +399,7 @@ export class ClaudeNativeAuthority {
395
399
  : quarantined ? { status: 'quarantined' }
396
400
  : { pendingOperationId: null, status: 'ready' }),
397
401
  handoff: { ...handoff, phase, sealedOutgoing: quarantined ? handoff.sealedOutgoing : null,
398
- runningEffectiveAuth, problem, updatedAt: now, revision: handoff.revision + 1, updateId },
402
+ runningEffectiveAuth, problem, proofFailure, updatedAt: now, revision: handoff.revision + 1, updateId },
399
403
  outgoing: this.grantStep(outgoing, updateId, now, committed
400
404
  ? { state: 'ready', owner: 'daemon', ciphertext: handoff.sealedOutgoing,
401
405
  accessExpiresAt: outgoingExpiry, problem: 'none',
@@ -448,8 +452,11 @@ export class ClaudeNativeAuthority {
448
452
  if (['committed', 'aborted', 'quarantined'].includes(context.handoff.phase)) return publicHandoff(context.handoff);
449
453
  try {
450
454
  return await this.options.adapter.withExclusiveHome(session => this.classify(operationId, session));
451
- } catch {
452
- return this.settle(operationId, 'quarantined', null, 'unsupported_effective_auth');
455
+ } catch (error) {
456
+ // Which condition of the proof failed is the one thing an owner can act on, so it is journaled
457
+ // rather than collapsed into the problem it explains.
458
+ return this.settle(operationId, 'quarantined', null, 'unsupported_effective_auth',
459
+ error instanceof ClaudeNativeEffectiveAuthUnsupportedError ? error.reason : null);
453
460
  }
454
461
  }
455
462
 
@@ -479,8 +486,13 @@ export class ClaudeNativeAuthority {
479
486
 
480
487
  private async performSwitch(accountId: string, loginId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
481
488
  const home = await this.database.readClaudeHome(this.options.homeId);
489
+ // Two different answers for the owner: there is no adopted home yet, or one move is already running.
490
+ if (home && home.status === 'ready' && home.pendingOperationId !== null) {
491
+ throw new AuthSwitchRefusal('claude_handoff_pending',
492
+ 'A Claude handoff is already running for this home; wait for it to finish, then switch again.');
493
+ }
482
494
  if (!home || home.status !== 'ready' || home.pendingOperationId !== null) {
483
- throw new Error('Verified Claude native home is unavailable or has a pending handoff.');
495
+ throw new AuthSwitchRefusal('claude_home_unregistered', 'Verified Claude native home is unavailable or has a pending handoff.');
484
496
  }
485
497
  await this.refreshInactiveGrant(loginId);
486
498
  const operationId = plugins.crypto.randomUUID();
@@ -517,7 +529,7 @@ export class ClaudeNativeAuthority {
517
529
 
518
530
  public async getHandoff(operationId: string): Promise<IAuthSwitchClaudeNativeHandoff> {
519
531
  const handoff = await this.database.readClaudeHandoff(operationId);
520
- if (!handoff || handoff.homeId !== this.options.homeId) throw new Error('Claude handoff operation was not found.');
532
+ if (!handoff || handoff.homeId !== this.options.homeId) throw new AuthSwitchRefusal('not_found', 'Claude handoff operation was not found.');
521
533
  return publicHandoff(handoff);
522
534
  }
523
535
 
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import type { TAuthSwitchClaudeProofFailure } from './authority-contract.js';
2
3
  import { ClaudeCodeLockOutcomeUnknownError, ClaudeCodeLocks,
3
4
  type IClaudeCodeLockLease } from './classes.claudecodelocks.js';
4
5
  import { claudeRequest } from './claudehttp.js';
@@ -34,9 +35,10 @@ const CREDENTIAL_OVERRIDES = [
34
35
  export class ClaudeNativeEffectiveAuthUnsupportedError extends Error {
35
36
  public readonly code = 'unsupported_effective_auth';
36
37
 
37
- constructor(public readonly reason: 'override' | 'profile' | 'profile_unreadable' | 'settings'
38
- | 'subscription' | 'running_session') {
39
- super(`Cannot prove that Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} uses its native claude.ai login (${reason}).`);
38
+ constructor(public readonly reason: TAuthSwitchClaudeProofFailure) {
39
+ 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}).`);
40
42
  }
41
43
  }
42
44
 
@@ -142,7 +144,15 @@ export interface IClaudeNativeAdapterOptions {
142
144
  executable?: string;
143
145
  platform?: NodeJS.Platform;
144
146
  env?: NodeJS.ProcessEnv;
145
- settingsFiles?: readonly string[];
147
+ /**
148
+ * The settings sources this proof reads, stated by the caller because only it knows which they are.
149
+ *
150
+ * There is no default: this adapter runs inside the daemon, whose working directory is the service
151
+ * unit's and never a Claude session's project, so a list derived from `process.cwd()` would prove a
152
+ * directory nobody selected. `claudeNativeUserSettingsFiles` is the daemon's list; the settings a
153
+ * running session selected are proven from that session's own working directory instead.
154
+ */
155
+ settingsFiles: readonly string[];
146
156
  /** Internal test seam; production uses Claude's Linux managed-settings directory. */
147
157
  managedSettingsDirectory?: string;
148
158
  fetch?: typeof fetch;
@@ -166,6 +176,87 @@ const preparedWrites = new WeakMap<IClaudeNativePreparedWrite, {
166
176
  const sameIdentity = (left: IClaudeNativeIdentity, right: IClaudeNativeIdentity): boolean =>
167
177
  left.accountUuid === right.accountUuid && left.organizationUuid === right.organizationUuid;
168
178
 
179
+ /** Claude Code's own paths on this host, and the authority's id for that one native home. */
180
+ export interface IClaudeNativeHome {
181
+ configDir: string;
182
+ configFile: string;
183
+ credentialFile: string;
184
+ homeId: string;
185
+ }
186
+
187
+ /**
188
+ * A home this host cannot have: the location it was pointed at, and why it is not usable.
189
+ *
190
+ * The inventory reports such a source with its defect rather than skipping it or reading a guessed
191
+ * location, so `declaredCredentialFile` names what the environment asked for, unresolved.
192
+ */
193
+ export interface IClaudeNativeHomeDefect {
194
+ declaredCredentialFile: string;
195
+ problem: string;
196
+ }
197
+
198
+ export type TClaudeNativeHomeResolution =
199
+ | { kind: 'resolved'; home: IClaudeNativeHome }
200
+ | { kind: 'unusable'; defect: IClaudeNativeHomeDefect };
201
+
202
+ /**
203
+ * The authority's id for one native Claude home: the hash of the credential file Claude Code owns.
204
+ *
205
+ * The import ledger publishes the same value as a source's `sourcePathHash`, and
206
+ * `registerClaudeHomeFromReceipt` adopts a home only when the two are equal, so both sides must derive it
207
+ * here and nowhere else.
208
+ */
209
+ export const claudeNativeHomeId = (credentialFile: string): string => hash(credentialFile);
210
+
211
+ /** An absolute, normalized directory without a trailing separator, or null for anything else. */
212
+ const canonicalDirectory = (value: string): string | null => {
213
+ if (!plugins.path.isAbsolute(value)) return null;
214
+ const normalized = plugins.path.normalize(value);
215
+ return normalized.length > 1 && normalized.endsWith(plugins.path.sep)
216
+ ? normalized.slice(0, -1) : normalized;
217
+ };
218
+
219
+ /**
220
+ * Where Claude Code keeps this user's login, resolved once for the daemon and the import inventory alike.
221
+ *
222
+ * `CLAUDE_CONFIG_DIR` selects the home; Claude Code keeps its account metadata beside the home directory
223
+ * unless that variable moves it. A relative directory is never resolved against the current working
224
+ * directory: a daemon and a CLI run from different places, so guessing would make them disagree about
225
+ * which file is the login.
226
+ */
227
+ export const resolveClaudeNativeHome = (env: NodeJS.ProcessEnv,
228
+ homeDirectory: string): TClaudeNativeHomeResolution => {
229
+ const declared = env.CLAUDE_CONFIG_DIR ? canonicalDirectory(env.CLAUDE_CONFIG_DIR) : null;
230
+ if (env.CLAUDE_CONFIG_DIR && declared === null) {
231
+ return { kind: 'unusable', defect: {
232
+ declaredCredentialFile: plugins.path.join(env.CLAUDE_CONFIG_DIR, '.credentials.json'),
233
+ problem: 'CLAUDE_CONFIG_DIR is not an absolute path, so Claude Code\'s own login cannot be located' } };
234
+ }
235
+ const home = canonicalDirectory(homeDirectory);
236
+ if (home === null) {
237
+ return { kind: 'unusable', defect: {
238
+ declaredCredentialFile: plugins.path.join(homeDirectory, '.claude', '.credentials.json'),
239
+ problem: 'the home directory is not an absolute path, so Claude Code\'s own login cannot be located' } };
240
+ }
241
+ const configDir = declared ?? plugins.path.join(home, '.claude');
242
+ const credentialFile = plugins.path.join(configDir, '.credentials.json');
243
+ return { kind: 'resolved', home: { configDir,
244
+ configFile: plugins.path.join(declared ?? home, '.claude.json'),
245
+ credentialFile, homeId: claudeNativeHomeId(credentialFile) } };
246
+ };
247
+
248
+ /**
249
+ * The settings sources Claude Code reads at user scope for one resolved home.
250
+ *
251
+ * This is what a host-wide owner -- the daemon -- may prove: the user settings file beside the credential
252
+ * file the home owns. Claude Code's other two scopes are proven where they are selected: the managed
253
+ * settings directory is checked on its own, and a project's `.claude/settings.json` and
254
+ * `.claude/settings.local.json` belong to the working directory of a running session, which is read from
255
+ * that session rather than from this process.
256
+ */
257
+ export const claudeNativeUserSettingsFiles = (configDir: string): readonly string[] =>
258
+ [plugins.path.join(configDir, 'settings.json')];
259
+
169
260
  const pathEnvironment = ['HOME', 'XDG_CONFIG_HOME', 'ANTHROPIC_CONFIG_DIR', 'CLAUDE_CONFIG_DIR'] as const;
170
261
  const contextKeys = new Set<string>([...CREDENTIAL_OVERRIDES, ...pathEnvironment]);
171
262
  const readProcessContext = (file: string): string => {
@@ -486,8 +577,13 @@ export class ClaudeNativeAdapter {
486
577
  || plugins.path.normalize(options.configFile) !== options.configFile) {
487
578
  throw new Error('Native Claude paths must be normalized absolute paths.');
488
579
  }
580
+ // Claude Code keeps its account metadata inside a configured home and beside an unconfigured one.
581
+ // Which home that is follows from the environment this adapter was given, the same environment
582
+ // `assertSupported` re-checks before every operation, never from the running process's own account.
583
+ const configParent = selectedNativeConfigParent(options.env ?? process.env);
489
584
  if (![plugins.path.join(options.configDir, '.claude.json'),
490
- plugins.path.join(plugins.os.homedir(), '.claude.json')].includes(options.configFile)) {
585
+ ...(configParent === null ? [] : [plugins.path.join(configParent, '.claude.json')]),
586
+ ].includes(options.configFile)) {
491
587
  throw new Error('Native Claude account metadata must use its vendor config file.');
492
588
  }
493
589
  this.credentialFile = plugins.path.join(options.configDir, '.credentials.json');
@@ -497,9 +593,7 @@ export class ClaudeNativeAdapter {
497
593
  options.executable ?? 'claude');
498
594
  this.fetcher = options.fetch ?? globalThis.fetch;
499
595
  this.env = options.env ?? process.env;
500
- this.settingsFiles = options.settingsFiles ?? [plugins.path.join(options.configDir, 'settings.json'),
501
- plugins.path.join(process.cwd(), '.claude/settings.json'),
502
- plugins.path.join(process.cwd(), '.claude/settings.local.json')];
596
+ this.settingsFiles = options.settingsFiles;
503
597
  this.platform = options.platform ?? process.platform;
504
598
  }
505
599
 
@@ -528,7 +622,7 @@ export class ClaudeNativeAdapter {
528
622
  assertSettingsSelectNative(file);
529
623
  }
530
624
  if (this.versionProbe.installedVersion() !== CLAUDE_NATIVE_HANDOFF_VERSION) {
531
- throw new Error(`Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`);
625
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
532
626
  }
533
627
  let runningSessions = 0;
534
628
  const nativeHomeIdentity = directoryIdentity(this.options.configDir);
@@ -543,7 +637,7 @@ export class ClaudeNativeAdapter {
543
637
  throw new ClaudeNativeEffectiveAuthUnsupportedError('running_session');
544
638
  }
545
639
  if (running.version !== CLAUDE_NATIVE_HANDOFF_VERSION) {
546
- throw new Error(`Native Claude handoff requires Claude Code ${CLAUDE_NATIVE_HANDOFF_VERSION} for every active session.`);
640
+ throw new ClaudeNativeEffectiveAuthUnsupportedError('unsupported_release');
547
641
  }
548
642
  if (!running.env || !running.cwd || running.hasSettingsOverride === null
549
643
  || !running.mountNamespace || !running.processRoot) {
package/ts/index.ts CHANGED
@@ -26,6 +26,7 @@ export * from './watchpolicy.js';
26
26
  export * from './classes.authorityclient.js';
27
27
  export * from './classes.authoritycli.js';
28
28
  export * from './classes.authoritydaemon.js';
29
+ export * from './classes.authorityimport.js';
29
30
  export * from './classes.authorityservice.js';
30
31
 
31
32
  import { AuthSwitchCli } from './classes.cli.js';
@@ -33,7 +34,8 @@ import { runAuthSwitchAuthorityCli } from './classes.authoritycli.js';
33
34
  import { AuthSwitchAuthorityService, resolveAuthSwitchAuthorityPaths, runAuthSwitchAuthorityDaemon } from './classes.authorityservice.js';
34
35
 
35
36
  export const runCli = async (argvArg: string[] = process.argv.slice(2)): Promise<void> => {
36
- if (argvArg[0] === 'account' || (argvArg[0] === 'authority' && argvArg[1] === 'doctor')) {
37
+ if (argvArg[0] === 'account'
38
+ || (argvArg[0] === 'authority' && (argvArg[1] === 'doctor' || argvArg[1] === 'import'))) {
37
39
  process.exitCode = await runAuthSwitchAuthorityCli(argvArg);
38
40
  return;
39
41
  }
@@ -57,7 +59,8 @@ export const runCli = async (argvArg: string[] = process.argv.slice(2)): Promise
57
59
  return;
58
60
  }
59
61
  }
60
- throw new Error('Usage: authswitch authority daemon | service install|status|enable|start|stop');
62
+ throw new Error('Usage: authswitch authority daemon | service install|status|enable|start|stop '
63
+ + '| doctor | import inventory');
61
64
  }
62
65
  const exitCode = await new AuthSwitchCli().run(argvArg);
63
66
  process.exitCode = exitCode;
@@ -0,0 +1,19 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * Gives every Claude native handoff journalled before 8.2.0 its `proofFailure` field.
5
+ *
6
+ * The handoff record is exact-persistence: a document with a key the shape does not declare, or without one
7
+ * it does, is refused rather than read loosely. A published release therefore cannot gain a field by
8
+ * declaring it -- the rows that already exist have to carry it too, and this is where that happens.
9
+ *
10
+ * Only the absence is filled, with `null`: no release before this one could say which condition of the
11
+ * native-login proof failed, so there is nothing to infer and nothing to overwrite. A row that already has
12
+ * the field is left exactly as it is, which is what makes an interrupted run safe to repeat.
13
+ */
14
+ export const addClaudeHandoffProofFailure = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
15
+ const handoffs = db.mongoDb.collection('authswitch_authority_claude_handoffs');
16
+ const missing = { proofFailure: { $exists: false } };
17
+ if (!await handoffs.findOne(missing)) return;
18
+ await handoffs.updateMany(missing, { $set: { proofFailure: null } });
19
+ };
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import { migrateAuthorityMeta } from './0001_authority_meta.js';
3
3
  import { removeAuthorityBackupRecords } from './0002_remove_backup_records.js';
4
+ import { addClaudeHandoffProofFailure } from './0003_claude_handoff_proof.js';
4
5
 
5
6
  /**
6
7
  * Runs on every start of an existing store, in this fixed order. Each module recognises the published shapes it
@@ -9,4 +10,5 @@ import { removeAuthorityBackupRecords } from './0002_remove_backup_records.js';
9
10
  export const runAuthorityMigrations = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
10
11
  await migrateAuthorityMeta(db);
11
12
  await removeAuthorityBackupRecords(db);
13
+ await addClaudeHandoffProofFailure(db);
12
14
  };