@modelprofile.com/authswitch 8.1.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +73 -0
  3. package/dist_ts/authority-contract.js +55 -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/authority-paths.d.ts +37 -0
  7. package/dist_ts/authority-paths.js +46 -0
  8. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  9. package/dist_ts/classes.authoritybroker.d.ts +9 -0
  10. package/dist_ts/classes.authoritybroker.js +72 -11
  11. package/dist_ts/classes.authorityclient.d.ts +17 -11
  12. package/dist_ts/classes.authorityclient.js +47 -22
  13. package/dist_ts/classes.authoritydaemon.d.ts +22 -3
  14. package/dist_ts/classes.authoritydaemon.js +106 -59
  15. package/dist_ts/classes.authoritydatabase.d.ts +9 -1
  16. package/dist_ts/classes.authoritydatabase.js +39 -13
  17. package/dist_ts/classes.authorityimport.d.ts +33 -6
  18. package/dist_ts/classes.authorityimport.js +102 -25
  19. package/dist_ts/classes.authoritymodels.d.ts +4 -1
  20. package/dist_ts/classes.authoritymodels.js +15 -3
  21. package/dist_ts/classes.authoritypreuse.js +8 -3
  22. package/dist_ts/classes.authorityservice.d.ts +20 -11
  23. package/dist_ts/classes.authorityservice.js +37 -23
  24. package/dist_ts/classes.claudeauthority.js +19 -11
  25. package/dist_ts/classes.claudenative.d.ts +63 -3
  26. package/dist_ts/classes.claudenative.js +68 -8
  27. package/dist_ts/classes.cli.d.ts +10 -2
  28. package/dist_ts/classes.cli.js +12 -4
  29. package/dist_ts/classes.codexmanaged.d.ts +8 -0
  30. package/dist_ts/classes.codexmanaged.js +19 -12
  31. package/dist_ts/classes.legacyfence.d.ts +53 -0
  32. package/dist_ts/classes.legacyfence.js +189 -0
  33. package/dist_ts/classes.operations.d.ts +15 -3
  34. package/dist_ts/classes.operations.js +22 -4
  35. package/dist_ts/classes.service.d.ts +21 -2
  36. package/dist_ts/classes.service.js +35 -8
  37. package/dist_ts/classes.tui.d.ts +2 -1
  38. package/dist_ts/classes.tui.js +3 -2
  39. package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
  40. package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
  41. package/dist_ts/ts_migration/index.js +3 -1
  42. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  43. package/dist_ts/ts_migration/legacysources/nativestores.js +15 -8
  44. package/dist_ts/ts_migration/legacysources/shared.d.ts +3 -2
  45. package/dist_ts/ts_migration/legacysources/shared.js +3 -5
  46. package/package.json +11 -7
  47. package/readme.md +188 -13
  48. package/ts/00_commitinfo_data.ts +1 -1
  49. package/ts/authority-contract.ts +125 -0
  50. package/ts/authority-import-contract.ts +12 -11
  51. package/ts/authority-paths.ts +69 -0
  52. package/ts/authority-runtime-contract.ts +11 -1
  53. package/ts/classes.authoritybroker.ts +70 -11
  54. package/ts/classes.authorityclient.ts +59 -21
  55. package/ts/classes.authoritydaemon.ts +116 -60
  56. package/ts/classes.authoritydatabase.ts +39 -12
  57. package/ts/classes.authorityimport.ts +131 -26
  58. package/ts/classes.authoritymodels.ts +11 -2
  59. package/ts/classes.authoritypreuse.ts +7 -1
  60. package/ts/classes.authorityservice.ts +42 -30
  61. package/ts/classes.claudeauthority.ts +22 -10
  62. package/ts/classes.claudenative.ts +104 -10
  63. package/ts/classes.cli.ts +14 -3
  64. package/ts/classes.codexmanaged.ts +18 -7
  65. package/ts/classes.legacyfence.ts +219 -0
  66. package/ts/classes.operations.ts +22 -3
  67. package/ts/classes.service.ts +45 -8
  68. package/ts/classes.tui.ts +3 -1
  69. package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
  70. package/ts/ts_migration/index.ts +2 -0
  71. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
  72. package/ts/ts_migration/legacysources/nativestores.ts +15 -7
  73. package/ts/ts_migration/legacysources/shared.ts +4 -6
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
3
3
  import { authSwitchEnvironmentId } from './classes.operations.js';
4
+ import { AuthSwitchRefusal } from './authority-contract.js';
4
5
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
5
6
  import { ClaudeAccountStatus, type IClaudeLogin, type IClaudeStatusRead } from './classes.claudestatus.js';
6
7
  import { ClaudeTokenRefresh, type TClaudeAuthorityRefreshResult } from './classes.claudetokenrefresh.js';
@@ -75,9 +76,15 @@ export interface IAuthSwitchImportClaudeTokens {
75
76
  }
76
77
 
77
78
  export interface IAuthSwitchAuthorityImportOptions {
78
- /** Locates the legacy stores, exactly as each owning class locates its own. */
79
- env?: NodeJS.ProcessEnv;
80
- homeDirectory?: string;
79
+ /**
80
+ * Locates the legacy stores, exactly as each owning class locates its own -- and always stated.
81
+ *
82
+ * These are the two inputs that decide whose real credentials an import reads, so the importer never
83
+ * reaches for the host's own environment: a caller that could not name them has no business importing.
84
+ * `AuthSwitchAuthorityService`'s entry point is the one place that takes them from this host.
85
+ */
86
+ env: NodeJS.ProcessEnv;
87
+ homeDirectory: string;
81
88
  now?: () => number;
82
89
  /** The one rotating refresh of an imported ChatGPT login, and the non-rotating read that proves one. */
83
90
  openAi?: IAuthSwitchImportOpenAiOperations;
@@ -168,8 +175,17 @@ const importAction = (status: TAuthSwitchImportLedgerStatus,
168
175
  return handoff !== null && spentHandoffPhases.includes(handoff.phase) ? 'device_login' : 'resume';
169
176
  };
170
177
 
171
- /** A refusal the owner can act on. It never leaves a half-written import behind. */
172
- export class AuthSwitchImportRefusal extends Error {}
178
+ /**
179
+ * A refusal the owner can act on. It never leaves a half-written import behind.
180
+ *
181
+ * One class hierarchy for the whole authority: this is the authority's refusal with the importer's code,
182
+ * which is what keeps the marker this route has published since 8.1.0 on the wire.
183
+ */
184
+ export class AuthSwitchImportRefusal extends AuthSwitchRefusal {
185
+ constructor(instruction: string) {
186
+ super('import_refusal', instruction);
187
+ }
188
+ }
173
189
 
174
190
  // The annotation on the binding, not only on the arrow, is what lets a refusal narrow the code after it.
175
191
  const refuse: (reason: string) => never = reason => { throw new AuthSwitchImportRefusal(reason); };
@@ -179,6 +195,58 @@ const RUN_ORDER_REFUSAL = 'Importing needs the run-order acknowledgement: an imp
179
195
  + 'exercise exactly that copy. Import only once those commands run on the authority and the native '
180
196
  + 'projections exist.';
181
197
 
198
+ /**
199
+ * A row keeps the login it named, permanently: no route forgets a row or re-points it at another account,
200
+ * and one would be a way to import a login the owner never approved under an approval given for a different
201
+ * one. The same is true of a settled row, which answers that it is already imported.
202
+ */
203
+ const FOREIGN_SOURCE_REFUSAL = 'This source now holds a different account\'s login than the one this import '
204
+ + 'registered for it. Restore the login this source was registered with, then import it again.';
205
+ const CHANGED_SOURCE_REFUSAL =
206
+ 'The source changed after its migration ledger row was written; it is a different source now.';
207
+
208
+ /**
209
+ * Whether a row may take the bytes its source holds now.
210
+ *
211
+ * A projection that could not be proven waits for its own tool, and that tool proves itself by refreshing
212
+ * its store -- which rewrites the file. The row has to be able to follow those bytes, or the retry its own
213
+ * refusal asks for could never be made at all. It follows them only this far: the row is unsettled, nothing
214
+ * rotating was ever sent for it, and the authority holds no copy of its credential. That the bytes are still
215
+ * the same login is a separate question, and `ledgerObjection` answers it first.
216
+ */
217
+ const followsRefreshedSource = (ledger: IStoredAuthorityMigrationLedger,
218
+ record: Pick<ILegacySourceRecord, 'treatment'>): boolean =>
219
+ ledger.status === 'pending_native_owner' && record.treatment === 'project' && ledger.grantId !== null;
220
+
221
+ /**
222
+ * What this source's own row objects to, asked once and answered for both the report and the submit.
223
+ *
224
+ * The runbook tells the owner not to submit a source the inventory lists a problem for, which is only true
225
+ * while the two agree, so they are the same four questions in one place. The identity question is the one
226
+ * the provider cannot answer: a row is found by the hash of its location, so the same row answers for
227
+ * whatever login that location holds later -- `codex login` under the same path, a replaced saved record --
228
+ * and the non-rotating proof asks the provider about the identity the newly read bytes themselves claim. It
229
+ * therefore proves those bytes are self-consistent and live, never that they are the login this row was
230
+ * approved for. A source with no derivable identity is left to its reader's own problem.
231
+ */
232
+ type TLedgerObjection = 'imported' | 'quarantined' | 'foreign' | 'changed';
233
+
234
+ const ledgerObjection = (ledger: IStoredAuthorityMigrationLedger,
235
+ record: Pick<ILegacySourceRecord, 'treatment' | 'accountId' | 'sourceDigest'>,
236
+ grantId: string | null): TLedgerObjection | null => {
237
+ // A quarantined row is terminal whatever its location holds now, and its answer -- sign in to that
238
+ // account again -- is the next step either way. Everything after it is about the login that is there.
239
+ if (ledger.status === 'quarantined') return 'quarantined';
240
+ // Before "already imported": a settled row whose location now holds another account would otherwise
241
+ // answer that a login it never touched is imported.
242
+ if (record.accountId !== null && grantId !== null
243
+ && ((ledger.accountId !== null && ledger.accountId !== record.accountId)
244
+ || (ledger.grantId !== null && ledger.grantId !== grantId))) return 'foreign';
245
+ if (ledger.status === 'complete') return 'imported';
246
+ if (ledger.sourceDigest !== record.sourceDigest && !followsRefreshedSource(ledger, record)) return 'changed';
247
+ return null;
248
+ };
249
+
182
250
  /** Strips the source location: a ledger row stores its hash, and no response may carry the path. */
183
251
  const publicSource = (record: ILegacySourceRecord,
184
252
  ledgerStatus: IAuthSwitchImportSource['ledgerStatus']): IAuthSwitchImportSource => ({
@@ -217,9 +285,9 @@ export class AuthSwitchAuthorityImport {
217
285
  private readonly quiescence: IAuthSwitchImportQuiescence;
218
286
 
219
287
  constructor(private readonly database: AuthSwitchAuthorityDatabase,
220
- options: IAuthSwitchAuthorityImportOptions = {}) {
221
- this.env = options.env ?? process.env;
222
- this.homeDirectory = options.homeDirectory ?? plugins.os.homedir();
288
+ options: IAuthSwitchAuthorityImportOptions) {
289
+ this.env = options.env;
290
+ this.homeDirectory = options.homeDirectory;
223
291
  this.now = options.now ?? Date.now;
224
292
  this.openAi = options.openAi ?? new plugins.flexAccounts.OpenAiProviderAdapter();
225
293
  this.claudeStatus = options.claudeStatus ?? new ClaudeAccountStatus();
@@ -238,12 +306,20 @@ export class AuthSwitchAuthorityImport {
238
306
  for (const record of report.sources) {
239
307
  const ledger = await this.database.readMigrationLedger(record.id);
240
308
  const source = publicSource(record, ledger?.status ?? null);
241
- if (ledger !== null && ledger.sourceDigest !== record.sourceDigest) {
242
- // The source changed after its row was written. A later import must not treat the approved bytes and
243
- // the current bytes as the same source; the ledger deliberately refuses to move that row.
309
+ // The same four questions the submit decides on, so a source the report invites is one the submit
310
+ // takes. New bytes of the same login under a row that is still waiting for them are not an objection:
311
+ // they are the retry that row asks for, and no problem line tells the owner to stop.
312
+ const objection = ledger === null ? null : ledgerObjection(ledger, record,
313
+ record.accountId === null ? null : importGrantId(record.accountId, grantPlan(record)));
314
+ if (objection === 'foreign') {
315
+ source.problems.push('this source now holds a different account\'s login than the one it was registered with');
316
+ }
317
+ if (objection === 'changed') {
318
+ // A later import must not treat the approved bytes and the current bytes as the same source; the
319
+ // ledger deliberately refuses to move that row.
244
320
  source.problems.push('the source changed after its migration ledger row was written');
245
321
  }
246
- if (ledger?.status === 'complete') {
322
+ if (objection === 'imported') {
247
323
  source.problems.push('this source is already imported; its legacy copy is retired');
248
324
  }
249
325
  sources.push(source);
@@ -298,17 +374,6 @@ export class AuthSwitchAuthorityImport {
298
374
  const record = this.resolveSource(request.submission);
299
375
  const plan = grantPlan(record);
300
376
  this.assertQuiescent(record);
301
- const ledger = await this.database.readMigrationLedger(record.id);
302
- if (ledger !== null) {
303
- if (ledger.sourceDigest !== record.sourceDigest) {
304
- refuse('The source changed after its migration ledger row was written; it is a different source now.');
305
- }
306
- if (ledger.status === 'complete') refuse('This source is already imported.');
307
- if (ledger.status === 'quarantined') {
308
- refuse('This source is quarantined: its outcome could not be established, so it needs a device '
309
- + 'sign-in for that account rather than another import.');
310
- }
311
- }
312
377
  const accountId = record.accountId;
313
378
  const subject = record.subject;
314
379
  const workspaceId = record.workspaceId;
@@ -316,6 +381,18 @@ export class AuthSwitchAuthorityImport {
316
381
  refuse('The source carries no provider identity the authority can bind an account to.');
317
382
  }
318
383
  const grantId = importGrantId(accountId, plan);
384
+ const ledger = await this.database.readMigrationLedger(record.id);
385
+ // Every one of these is decided from the durable row alone, before a single write: a projection
386
+ // registers its account and grant before the provider is asked anything, so an objection that arrived
387
+ // later would arrive after the record it objects to had been written.
388
+ switch (ledger === null ? null : ledgerObjection(ledger, record, grantId)) {
389
+ case 'imported': refuse('This source is already imported.');
390
+ case 'quarantined':
391
+ refuse('This source is quarantined: its outcome could not be established, so it needs a device '
392
+ + 'sign-in for that account rather than another import.');
393
+ case 'foreign': refuse(FOREIGN_SOURCE_REFUSAL);
394
+ case 'changed': refuse(CHANGED_SOURCE_REFUSAL);
395
+ }
319
396
  if (ledger === null) await this.beginLedger(record);
320
397
  const material = this.material(record, request.submission);
321
398
  return record.treatment === 'project'
@@ -418,12 +495,20 @@ export class AuthSwitchAuthorityImport {
418
495
  }));
419
496
  }
420
497
 
498
+ /**
499
+ * Moves the row, and -- only where a caller states it -- the bytes that move with the status.
500
+ *
501
+ * The digest is part of the same write as the status it was proven under, so no row can record a proof of
502
+ * bytes it does not name. The database holds the rule about which row may carry a new digest at all.
503
+ */
421
504
  private async moveLedger(ledgerId: string, status: IStoredAuthorityMigrationLedger['status'],
422
- accountId: string | null, grantId: string | null): Promise<IStoredAuthorityMigrationLedger> {
505
+ accountId: string | null, grantId: string | null,
506
+ sourceDigest?: string): Promise<IStoredAuthorityMigrationLedger> {
423
507
  const updateId = plugins.crypto.randomUUID();
424
508
  return this.database.changeMigrationLedger(updateId, ledgerId, current => {
425
509
  if (!current) throw new Error('The migration ledger row disappeared during the import.');
426
- return { ...current, status, accountId, grantId, revision: current.revision + 1,
510
+ return { ...current, status, accountId, grantId,
511
+ sourceDigest: sourceDigest ?? current.sourceDigest, revision: current.revision + 1,
427
512
  statusObservedAt: this.stamp(), updateId };
428
513
  });
429
514
  }
@@ -503,11 +588,31 @@ export class AuthSwitchAuthorityImport {
503
588
  await this.moveLedger(record.id, 'pending_native_owner', identity.accountId, identity.grantId);
504
589
  throw error;
505
590
  }
506
- await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId);
591
+ // The bytes that were just proven are the bytes this row records, written with the proof itself: a row
592
+ // that followed its tool's refresh names the store the provider answered for, never the one before it.
593
+ await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId, record.sourceDigest);
594
+ if (record.sourceKind === 'claude_native') await this.adoptClaudeHome(record, identity);
507
595
  await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
508
596
  return this.result(record, 'complete', identity, []);
509
597
  }
510
598
 
599
+ /**
600
+ * Adopts the native Claude home this source is, so the daemon may serve `claude.*` for it.
601
+ *
602
+ * The verified receipt is the only thing that may adopt a home, and the source id it was earned with is
603
+ * the home id, so nothing else has to be trusted here. Re-submitting the same source returns the same
604
+ * home record rather than writing a second one.
605
+ */
606
+ private async adoptClaudeHome(record: ILegacySourceRecord,
607
+ identity: { accountId: string; subject: string; workspaceId: string; grantId: string }): Promise<void> {
608
+ await this.database.registerClaudeHomeFromReceipt({
609
+ homeId: record.sourcePathHash, accountId: identity.accountId, grantId: identity.grantId,
610
+ receiptId: record.id, sourceDigest: record.sourceDigest,
611
+ accountUuid: identity.subject, organizationUuid: identity.workspaceId,
612
+ updateId: plugins.crypto.randomUUID(), now: this.stamp(),
613
+ });
614
+ }
615
+
511
616
  /** The non-rotating proof: the stored access token is asked about itself and must name this account. */
512
617
  private async proveWithoutRotation(record: ILegacySourceRecord,
513
618
  identity: { accountId: string; subject: string; workspaceId: string },
@@ -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 {
@@ -155,6 +155,8 @@ export interface IStoredAuthorityClaudeHandoff {
155
155
  sealedOutgoing: string | null;
156
156
  runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth' | null;
157
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;
158
160
  startedAt: string;
159
161
  updatedAt: string;
160
162
  revision: number;
@@ -502,7 +504,7 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
502
504
  if (!object(value) || !exactKeys(value, ['id', 'homeId', 'outgoingAccountId', 'outgoingGrantId',
503
505
  'incomingAccountId', 'incomingGrantId', 'phase', 'before', 'after', 'sourceIdentity',
504
506
  'targetIdentity', 'sourceAccessDigest', 'targetAccessDigest', 'sealedOutgoing',
505
- 'runningEffectiveAuth', 'problem', 'startedAt', 'updatedAt', 'revision', 'updateId'])
507
+ 'runningEffectiveAuth', 'problem', 'proofFailure', 'startedAt', 'updatedAt', 'revision', 'updateId'])
506
508
  || !uuid(value.id) || !hash(value.homeId) || !hash(value.outgoingAccountId)
507
509
  || !hash(value.outgoingGrantId) || !hash(value.incomingAccountId) || !hash(value.incomingGrantId)
508
510
  || value.outgoingAccountId === value.incomingAccountId || value.outgoingGrantId === value.incomingGrantId
@@ -514,8 +516,14 @@ export const assertStoredAuthorityClaudeHandoff: (value: unknown) => asserts val
514
516
  || !ciphertext(value.sealedOutgoing)
515
517
  || ![null, 'no_scoped_sessions', 'unsupported_effective_auth'].includes(value.runningEffectiveAuth as null)
516
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)
517
521
  || !iso(value.startedAt) || !iso(value.updatedAt) || !revision(value.revision)
518
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
+ }
519
527
  const hasProof = value.before !== null && value.after !== null
520
528
  && value.sourceAccessDigest !== null && value.targetAccessDigest !== null;
521
529
  const hasNoProof = value.before === null && value.after === null
@@ -838,6 +846,7 @@ export class AuthSwitchAuthorityClaudeHandoffModel extends plugins.nosqldb.Smart
838
846
  @plugins.nosqldb.svDb() public sealedOutgoing!: string | null;
839
847
  @plugins.nosqldb.svDb() public runningEffectiveAuth!: IStoredAuthorityClaudeHandoff['runningEffectiveAuth'];
840
848
  @plugins.nosqldb.svDb() public problem!: IStoredAuthorityClaudeHandoff['problem'];
849
+ @plugins.nosqldb.svDb() public proofFailure!: IStoredAuthorityClaudeHandoff['proofFailure'];
841
850
  @plugins.nosqldb.svDb() public startedAt!: string;
842
851
  @plugins.nosqldb.svDb() public updatedAt!: string;
843
852
  @plugins.nosqldb.svDb() public revision!: number;
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchPreuseOperation } from './authority-contract.js';
3
+ import { AuthSwitchRefusal } from './authority-contract.js';
3
4
  import { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
4
5
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
5
6
  import type { IStoredAuthorityPreuseOperation, TStoredAuthorityPreuseProblem } from './classes.authoritymodels.js';
@@ -250,7 +251,12 @@ export class AuthSwitchAuthorityPreuse {
250
251
  private async readStored(operationId: string): Promise<IStoredAuthorityPreuseOperation> {
251
252
  if (!uuid(operationId)) throw new Error('Invalid preuse operation ID.');
252
253
  const operation = await this.database.readOperation(operationId);
253
- if (!operation || operation.kind !== 'preuse_openai') throw new Error('Preuse operation was not found.');
254
+ // One site answers both routes that take a preuse operation id, so a read and a cancel of something
255
+ // this authority does not hold say the same thing in the same code.
256
+ if (!operation || operation.kind !== 'preuse_openai') {
257
+ throw new AuthSwitchRefusal('not_found', 'This authority knows no preuse by that operation ID. '
258
+ + 'Start a new preuse and follow that one.');
259
+ }
254
260
  return operation;
255
261
  }
256
262
 
@@ -1,34 +1,12 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { authSwitchAuthorityUnitDirectory, authSwitchAuthorityUnitName,
3
+ resolveAuthSwitchAuthorityPaths } from './authority-paths.js';
2
4
  import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
5
+ import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
6
+ import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
7
+ resolveClaudeNativeHome } from './classes.claudenative.js';
3
8
 
4
- export interface IAuthSwitchAuthorityPaths {
5
- runtimeDirectory: string;
6
- dataDirectory: string;
7
- databaseSocketPath: string;
8
- authoritySocketPath: string;
9
- runtimeSocketPath: string;
10
- runtimeSocketDirectory: string;
11
- }
12
-
13
- /** Canonical per-user paths. An explicit runtime directory is also usable by a trusted container host. */
14
- export const resolveAuthSwitchAuthorityPaths = (runtimeDirectory = process.env.XDG_RUNTIME_DIR): IAuthSwitchAuthorityPaths => {
15
- if (!runtimeDirectory || !plugins.path.isAbsolute(runtimeDirectory)) {
16
- throw new Error('A private XDG_RUNTIME_DIR is required for authswitch authority.');
17
- }
18
- const home = plugins.os.userInfo().homedir;
19
- const dataHome = process.env.XDG_DATA_HOME || plugins.path.join(home, '.local/share');
20
- if (!plugins.path.isAbsolute(dataHome)) throw new Error('XDG_DATA_HOME must be an absolute path.');
21
- const socketDirectory = plugins.path.join(runtimeDirectory, 'authswitch');
22
- const runtimeSocketDirectory = plugins.path.join(socketDirectory, 'runtime');
23
- return {
24
- runtimeDirectory,
25
- dataDirectory: plugins.path.join(dataHome, 'authswitch', 'authority'),
26
- databaseSocketPath: plugins.path.join(socketDirectory, 'internal', 'db.sock'),
27
- authoritySocketPath: plugins.path.join(socketDirectory, 'management', 'authority.sock'),
28
- runtimeSocketPath: plugins.path.join(runtimeSocketDirectory, 'runtime.sock'),
29
- runtimeSocketDirectory,
30
- };
31
- };
9
+ export { resolveAuthSwitchAuthorityPaths, type IAuthSwitchAuthorityPaths } from './authority-paths.js';
32
10
 
33
11
  /** Explicit installer and lifecycle operations for a user-scoped systemd service. */
34
12
  export class AuthSwitchAuthorityService {
@@ -36,9 +14,11 @@ export class AuthSwitchAuthorityService {
36
14
  public readonly unitFile: plugins.smartdaemon.SystemdUnitFile;
37
15
  public readonly definition: plugins.smartdaemon.SystemdServiceDefinition;
38
16
 
39
- constructor(runtimeDirectory: string, unitDirectory?: string) {
17
+ /** The unit directory defaults to the one the legacy fence looks in for this user (`./authority-paths`). */
18
+ constructor(runtimeDirectory: string,
19
+ unitDirectory = authSwitchAuthorityUnitDirectory(process.env, plugins.os.userInfo().homedir)) {
40
20
  const packageRoot = plugins.path.dirname(plugins.path.dirname(plugins.fileURLToPath(import.meta.url)));
41
- const options = { unitName: 'authswitch-authority.service', scope: 'user' as const, runtimeDirectory };
21
+ const options = { unitName: authSwitchAuthorityUnitName, scope: 'user' as const, runtimeDirectory };
42
22
  this.unit = new plugins.smartdaemon.SystemdUnit(options);
43
23
  this.unitFile = new plugins.smartdaemon.SystemdUnitFile({ ...options, unitDirectory });
44
24
  this.definition = new plugins.smartdaemon.SystemdServiceDefinition({
@@ -60,12 +40,44 @@ export class AuthSwitchAuthorityService {
60
40
  public stop(): Promise<plugins.smartdaemon.ISystemdUnitState> { return this.unit.stop(); }
61
41
  }
62
42
 
43
+ /**
44
+ * The Claude native authority this user's daemon serves.
45
+ *
46
+ * It is wired whether or not Claude Code is installed: nothing here touches the filesystem, and every
47
+ * operation that needs the native home refuses until a verified import receipt has registered it. An
48
+ * environment that names a home this daemon cannot resolve is a named startup failure rather than a
49
+ * silently missing capability, because the same environment also decides which file the import reads.
50
+ */
51
+ export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv = process.env,
52
+ homeDirectory: string = plugins.os.homedir()): IClaudeNativeAuthorityOptions => {
53
+ const resolution = resolveClaudeNativeHome(env, homeDirectory);
54
+ if (resolution.kind === 'unusable') {
55
+ throw new Error(`Authswitch authority cannot locate Claude Code's native home: ${resolution.defect.problem}.`);
56
+ }
57
+ const { configDir, configFile, homeId } = resolution.home;
58
+ // The adapter re-derives which home Claude Code itself selects; it must read the environment this
59
+ // home was resolved from, or the two could disagree about the same host. Its settings sources are
60
+ // stated from that same home: this daemon's working directory is the service unit's own (the package
61
+ // root, see `AuthSwitchAuthorityService`), so a project settings file beside it selects nothing.
62
+ return { homeId, adapter: new ClaudeNativeAdapter({ configDir, configFile, env,
63
+ settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
64
+ };
65
+
66
+ /**
67
+ * The per-user daemon this package installs, and the one place that names this host's own legacy stores.
68
+ *
69
+ * Every other construction of `AuthSwitchAuthorityDaemon` -- a test, a consumer's harness, an embedding
70
+ * backend -- states its own locations or asks for no import surface, because a daemon that defaulted here
71
+ * would read the credentials of whichever user happened to start it.
72
+ */
63
73
  export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
64
74
  const daemon = new AuthSwitchAuthorityDaemon({
65
75
  dataDirectory: paths.dataDirectory,
66
76
  socketPath: paths.databaseSocketPath,
67
77
  authoritySocketPath: paths.authoritySocketPath,
68
78
  runtimeSocketPath: paths.runtimeSocketPath,
79
+ claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
80
+ legacyImport: { env: process.env, homeDirectory: plugins.os.homedir() },
69
81
  });
70
82
  await daemon.start();
71
83
  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