@modelprofile.com/authswitch 8.1.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 (45) 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 +8 -4
  5. package/dist_ts/authority-import-contract.js +12 -13
  6. package/dist_ts/classes.authoritybroker.js +3 -2
  7. package/dist_ts/classes.authorityclient.d.ts +17 -11
  8. package/dist_ts/classes.authorityclient.js +28 -22
  9. package/dist_ts/classes.authoritydaemon.d.ts +8 -0
  10. package/dist_ts/classes.authoritydaemon.js +74 -58
  11. package/dist_ts/classes.authoritydatabase.d.ts +9 -1
  12. package/dist_ts/classes.authoritydatabase.js +29 -12
  13. package/dist_ts/classes.authorityimport.d.ts +17 -2
  14. package/dist_ts/classes.authorityimport.js +29 -3
  15. package/dist_ts/classes.authoritymodels.d.ts +4 -1
  16. package/dist_ts/classes.authoritymodels.js +15 -3
  17. package/dist_ts/classes.authorityservice.d.ts +10 -0
  18. package/dist_ts/classes.authorityservice.js +24 -1
  19. package/dist_ts/classes.claudeauthority.js +19 -11
  20. package/dist_ts/classes.claudenative.d.ts +63 -3
  21. package/dist_ts/classes.claudenative.js +68 -8
  22. package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
  23. package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
  24. package/dist_ts/ts_migration/index.js +3 -1
  25. package/dist_ts/ts_migration/legacysources/nativestores.js +15 -8
  26. package/dist_ts/ts_migration/legacysources/shared.d.ts +3 -2
  27. package/dist_ts/ts_migration/legacysources/shared.js +3 -5
  28. package/package.json +5 -1
  29. package/readme.md +65 -2
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/authority-contract.ts +106 -0
  32. package/ts/authority-import-contract.ts +12 -11
  33. package/ts/classes.authoritybroker.ts +2 -1
  34. package/ts/classes.authorityclient.ts +43 -21
  35. package/ts/classes.authoritydaemon.ts +71 -56
  36. package/ts/classes.authoritydatabase.ts +29 -11
  37. package/ts/classes.authorityimport.ts +30 -2
  38. package/ts/classes.authoritymodels.ts +11 -2
  39. package/ts/classes.authorityservice.ts +27 -0
  40. package/ts/classes.claudeauthority.ts +22 -10
  41. package/ts/classes.claudenative.ts +104 -10
  42. package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
  43. package/ts/ts_migration/index.ts +2 -0
  44. package/ts/ts_migration/legacysources/nativestores.ts +15 -7
  45. package/ts/ts_migration/legacysources/shared.ts +4 -6
@@ -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) {
@@ -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
  };
@@ -1,5 +1,6 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { CodexHome } from '../../classes.codexhome.js';
3
+ import { claudeNativeHomeId } from '../../classes.claudenative.js';
3
4
  import { accessProofAvailable, accessTokenExpiry, claudeSourceIdentity, EXPIRED_ACCESS_PROBLEM, legacyHash,
4
5
  legacySourceDigest, legacySourceId, openAiSourceIdentity, openCodeOpenAiIdentity, parseLegacyJson,
5
6
  readLegacyFile, type ILegacySourceFile, type ILegacySourceLocations, type ILegacySourceRecord,
@@ -48,9 +49,9 @@ const countCodexEnrollments = (codexHome: CodexHome): { count: number | null; pr
48
49
  } finally { database?.close(); }
49
50
  };
50
51
 
51
- const nativeRecord = (sourceKind: TAuthSwitchImportSourceKind, path: string,
52
+ /** `sourcePathHash` is passed in, because a Claude home's id belongs to the module that owns that home. */
53
+ const nativeRecord = (sourceKind: TAuthSwitchImportSourceKind, path: string, sourcePathHash: string,
52
54
  files: readonly ILegacySourceFile[], problems: string[]): ILegacySourceRecord => {
53
- const sourcePathHash = legacyHash(path);
54
55
  return {
55
56
  id: legacySourceId(sourceKind, sourcePathHash),
56
57
  sourceKind,
@@ -86,7 +87,7 @@ const readCodexNative = (locations: ILegacySourceLocations, now: number): ILegac
86
87
  if (read.problem === null && read.file === null) return null;
87
88
  const files = read.file ? [read.file] : [];
88
89
  const problems = read.problem ? [read.problem] : [];
89
- const record = nativeRecord('codex_native', path, files, problems);
90
+ const record = nativeRecord('codex_native', path, legacyHash(path), files, problems);
90
91
  const enrollments = countCodexEnrollments(new CodexHome(locations.codexHome));
91
92
  record.enrollmentCount = enrollments.count;
92
93
  if (enrollments.problem) problems.push(enrollments.problem);
@@ -114,12 +115,19 @@ const readCodexNative = (locations: ILegacySourceLocations, now: number): ILegac
114
115
  };
115
116
 
116
117
  const readClaudeNative = (locations: ILegacySourceLocations, now: number): ILegacySourceRecord | null => {
117
- const credentials = readLegacyFile(locations.claudeCredentialFile, '.credentials.json');
118
- const config = readLegacyFile(locations.claudeConfigFile, '.claude.json');
118
+ if (locations.claudeHome.kind === 'unusable') {
119
+ // Reported, never skipped and never read from a guessed location: the owner sees the defect and fixes it.
120
+ const { declaredCredentialFile, problem } = locations.claudeHome.defect;
121
+ return nativeRecord('claude_native', declaredCredentialFile,
122
+ claudeNativeHomeId(declaredCredentialFile), [], [problem]);
123
+ }
124
+ const { credentialFile, configFile, homeId } = locations.claudeHome.home;
125
+ const credentials = readLegacyFile(credentialFile, '.credentials.json');
126
+ const config = readLegacyFile(configFile, '.claude.json');
119
127
  if (credentials.problem === null && credentials.file === null) return null;
120
128
  const files = [credentials.file, config.file].filter((file): file is ILegacySourceFile => file !== null);
121
129
  const problems = [credentials.problem, config.problem].filter((problem): problem is string => problem !== null);
122
- const record = nativeRecord('claude_native', locations.claudeCredentialFile, files, problems);
130
+ const record = nativeRecord('claude_native', credentialFile, homeId, files, problems);
123
131
  if (!credentials.file) return record;
124
132
  const credentialData = parseLegacyJson(credentials.file.raw);
125
133
  const configData = config.file === null ? null : parseLegacyJson(config.file.raw);
@@ -157,7 +165,7 @@ const readOpenCodeNative = (locations: ILegacySourceLocations, now: number): ILe
157
165
  if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
158
166
  const entry = value as Record<string, unknown>;
159
167
  const path = `${locations.openCodeAuthFile}#${provider}`;
160
- const record = nativeRecord('opencode_native', path,
168
+ const record = nativeRecord('opencode_native', path, legacyHash(path),
161
169
  [{ name: provider, raw: JSON.stringify(entry) }], []);
162
170
  record.label = provider;
163
171
  if (entry.type !== 'oauth') {
@@ -1,5 +1,6 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { readCredentialRaw } from '../../classes.credentialstore.js';
3
+ import { resolveClaudeNativeHome, type TClaudeNativeHomeResolution } from '../../classes.claudenative.js';
3
4
  import type { TAuthSwitchImportProof, TAuthSwitchImportSourceKind,
4
5
  TAuthSwitchImportTreatment } from '../../authority-import-contract.js';
5
6
 
@@ -50,21 +51,18 @@ export interface ILegacySourceReport {
50
51
  export interface ILegacySourceLocations {
51
52
  authSwitchHome: string;
52
53
  codexHome: string;
53
- claudeCredentialFile: string;
54
- claudeConfigFile: string;
54
+ /** Claude's own paths come from the module that owns them, so the daemon and this reader cannot drift. */
55
+ claudeHome: TClaudeNativeHomeResolution;
55
56
  openCodeAuthFile: string;
56
57
  }
57
58
 
58
59
  export const resolveLegacySourceLocations = (env: NodeJS.ProcessEnv,
59
60
  homeDirectory: string): ILegacySourceLocations => {
60
- const claudeConfigDirectory = env.CLAUDE_CONFIG_DIR || plugins.path.join(homeDirectory, '.claude');
61
61
  const dataHome = env.XDG_DATA_HOME || plugins.path.join(homeDirectory, '.local', 'share');
62
62
  return {
63
63
  authSwitchHome: env.AUTHSWITCH_HOME || plugins.path.join(homeDirectory, '.authswitch'),
64
64
  codexHome: env.CODEX_HOME || plugins.path.join(homeDirectory, '.codex'),
65
- claudeCredentialFile: plugins.path.join(claudeConfigDirectory, '.credentials.json'),
66
- // Claude Code keeps its account metadata beside the home directory, not inside a custom config directory.
67
- claudeConfigFile: plugins.path.join(env.CLAUDE_CONFIG_DIR || homeDirectory, '.claude.json'),
65
+ claudeHome: resolveClaudeNativeHome(env, homeDirectory),
68
66
  openCodeAuthFile: plugins.path.join(dataHome, 'opencode', 'auth.json'),
69
67
  };
70
68
  };