@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
package/readme.md CHANGED
@@ -32,7 +32,11 @@ bindings, durable device-operation receipts, Codex enrollment records, and nativ
32
32
  owns OpenAI device login, targeted reauthentication, and proactive refresh for grants
33
33
  added through this API. Management snapshots expose separate account and login rows,
34
34
  including each login's purpose, owner, health, problem and available actions; neither
35
- snapshots nor events contain credentials.
35
+ snapshots nor events contain credentials. A login that a native tool refreshes also names
36
+ that tool -- `ownerTool` is `claude_code`, `codex` or `opencode`, and `null` exactly when no
37
+ native tool holds it, because the authority refreshes it or nothing does. The authority
38
+ names it rather than leaving a consumer to derive it from the purpose, which would be wrong
39
+ as soon as two purposes share a tool or one tool gains a second purpose.
36
40
 
37
41
  ```ts
38
42
  import { randomUUID } from 'node:crypto';
@@ -49,10 +53,16 @@ const receipt = await client.getOperation(operation.id);
49
53
  ```
50
54
 
51
55
  Browser code can import the credential-free DTOs from
52
- `@modelprofile.com/authswitch/authority-contract`. A backend binds an account to a
53
- runtime incarnation by exact account ID, login ID and purpose, and keeps the returned
54
- capability private. Targeted reauthentication likewise requires the exact login; an
55
- account's presentation default never selects a grant for either action. Completed and
56
+ `@modelprofile.com/authswitch/authority-contract`. The backend-only binding, access and release
57
+ requests are named by `@modelprofile.com/authswitch/authority-runtime-contract`, and the one-time
58
+ import's own DTOs by `@modelprofile.com/authswitch/authority-import-contract`; those capabilities must
59
+ never reach a browser. Two of the three runtime requests are served on the runtime socket -- resolving
60
+ access and releasing a binding -- while `authswitch.authority.bind` is served on the **management** socket
61
+ by design: minting a capability is the act that authorizes a runtime, so only a trusted management caller
62
+ may perform it, and a container that is given the runtime directory can use a binding it was handed but can
63
+ never create one. A backend binds an account to a runtime incarnation by exact account ID, login ID and
64
+ purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
65
+ exact login; an account's presentation default never selects a grant for either action. Completed and
56
66
  interrupted operations remain discoverable through `getOperation()` and paged
57
67
  `listOperations()`. A completed receipt and its account/grant change commit together, so
58
68
  the receipt resolves a lost response or an event that arrives first. Add and reauthentication
@@ -76,6 +86,48 @@ the runtime and clears its durable run before internal revocation. Binding relea
76
86
  or changes ownership of the account grant. External-binding recovery after an owner crash remains
77
87
  a prerequisite for the full account-mutation cutover.
78
88
 
89
+ Every route on either socket answers in one of three ways: a result, a **refusal**, or a fault. A refusal
90
+ is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
91
+ owner does instead -- so it crosses the wire marked, with the message verbatim beside
92
+ `{ reason, code }`. A fault is anything the daemon did not decide: the transport replaces its text with
93
+ `Internal server error`, and a caller must never present that as if it said what to do next. A malformed
94
+ request is neither; it keeps its own text and carries no marker, because a caller's own bug is not an
95
+ instruction for an owner.
96
+
97
+ `asAuthSwitchRefusal(error)` from `@modelprofile.com/authswitch/authority-contract` is the one reader of
98
+ that marker: it answers `{ code, instruction }` for a refusal and `null` for a fault. The code is the
99
+ closed `TAuthSwitchRefusalCode` set, which is what a client branches on without reading the text:
100
+ `authority_closing`, `not_found`, `account_changed`, `account_busy`, `login_unavailable`,
101
+ `binding_unauthorized`, `native_owner_holds_login`, `claude_home_unregistered`, `claude_handoff_pending`,
102
+ `claude_receipt_missing` and `import_refusal`, plus the two the legacy commands answer with on this side of
103
+ the socket, `authority_holds_login` and `authority_unavailable` (see "The legacy fence" below). The
104
+ instruction is authored for the owner and contains no path, credential or digest; only an importer refusal
105
+ may name the process the owner has to stop. The set is producer-owned: a consumer branches on the codes it
106
+ knows and keeps a default branch, which is what lets the authority name a new one without breaking it.
107
+
108
+ The runtime's own answers are named the same way. `authswitch.authority.bind` refuses `login_unavailable`
109
+ when the authority does not hold the login itself, and `authswitch.authority.resolveAccess` refuses
110
+ `binding_unauthorized` for a capability that authorizes nothing -- missing, wrong, or superseded while the
111
+ credential was being resolved. Those are one answer on purpose: an absent binding and a wrong capability are
112
+ compared against the same zero hash, so nothing distinguishes them, and the repair is the same either way --
113
+ bind again. A binding whose account has since been reauthorized reads `login_unavailable` instead, because
114
+ binding again cannot help until that account holds a login again. A device sign-in or preuse operation this
115
+ authority does not hold reads `not_found`, from whichever route met it. Two things stay unmarked faults on
116
+ purpose. What the shared managed-access loop refuses is one: it serves a bound runtime and the
117
+ credential-free usage reader alike, and those two repair differently. Binding an *account* this authority
118
+ does not hold at all is the other: the storage layer throws `Account is not available for this runtime.`,
119
+ which a socket caller reads as `Internal server error`, because naming it is a decision about which layer
120
+ authors owner instructions rather than a wording fix.
121
+
122
+ The import routes keep the marker they published in 8.1.0, `authswitch_import_refusal`, and
123
+ `isAuthSwitchImportRefusal` from `@modelprofile.com/authswitch/authority-import-contract` is the same
124
+ reader asking for the `import_refusal` code, so a consumer written against 8.1.0 keeps working and a
125
+ client of this release still reads the payload a daemon of that release answers with. The distinction
126
+ matters most for `import.submit`: a refusal there is a submit that decided -- it left nothing half-done,
127
+ and the ledger row it wrote says where that source stands -- while an unmarked failure leaves the outcome
128
+ unknown, and that source must then be read with `authswitch.authority.import.status` rather than
129
+ submitted again.
130
+
79
131
  `AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus })` reports `current`
80
132
  after a fresh snapshot or verified heartbeat, `unavailable` on disconnect or resync, and
81
133
  `closed` on abort. Keep cached account actions disabled while status is not `current`.
@@ -167,6 +219,35 @@ If the daemon is unavailable before the first response, it fails with a fixed di
167
219
  stderr and leaves stdout empty. The older native-store commands documented below still coexist until the coordinated
168
220
  major-version migration and removal; these authority routes never use them.
169
221
 
222
+ ### The Claude native home
223
+
224
+ The daemon serves Claude Code's own credential store, which stays the native store that Claude Code
225
+ reads. `authswitch.authority.claude.switch`, `.handoff` and `.handoffs` on the management socket move
226
+ that one home between two authority logins and report the journal of those moves, and
227
+ `authswitch.authority.usage` answers for an Anthropic account from it. The home is
228
+ `CLAUDE_CONFIG_DIR` or `~/.claude`, and the authority identifies it by the hash of the credential file
229
+ Claude Code owns -- the same value the import publishes as that source's path hash, which is what lets a
230
+ verified receipt adopt it.
231
+
232
+ The daemon is wired for that home whether or not Claude Code is installed, so `doctor` reports
233
+ `claudeNativeAuthority: configured` on any host; the `claudeHomes` page is what says whether a home has
234
+ been adopted. Until a verified `claude_native` import has adopted one, every Claude route refuses. A
235
+ `CLAUDE_CONFIG_DIR` that is not an absolute path is refused when the daemon starts, because the same
236
+ variable also decides which file the import would read; it is never resolved against a working directory.
237
+
238
+ A switch is only attempted when the host can prove Claude Code uses that native login: exactly the
239
+ verified Claude Code release, a subscriber login in the store, no credential override in the
240
+ environment, no managed or local settings selecting another auth source, and no scoped Claude session
241
+ running. Anything else refuses and leaves both logins where they were. A move that reaches the home and
242
+ then cannot complete that proof is quarantined with `problem: unsupported_effective_auth`, and the
243
+ handoff's optional `proofFailure` names which condition failed -- `unsupported_release`, `subscription`,
244
+ `override`, `settings`, `profile`, `profile_unreadable` or `running_session` -- so an owner is told what
245
+ to change. It is absent whenever `problem` is anything else. The settings that proof reads are the ones
246
+ the daemon may speak for: the user settings file of the resolved home and the managed settings directory.
247
+ A project's `.claude/settings.json` is read from the working directory of each running Claude session,
248
+ never from the daemon's own, which is its service unit's and selects nothing. While a login is in the
249
+ native home, Claude Code is its only refresher; the authority takes it back by the same journaled handoff.
250
+
170
251
  ## The one-time account import
171
252
 
172
253
  The three `authority import` commands move the accounts that existed before the authority into it.
@@ -189,6 +270,12 @@ Each source is classified by who refreshes its login **after** the import.
189
270
  credential. It is verified by a provider read with the access token already in the store, which rotates
190
271
  nothing. Two refreshers on one rotating refresh token is the failure this avoids.
191
272
 
273
+ A verified Claude projection also adopts the native home it came from, so the daemon may serve Claude
274
+ switches for it. The receipt is what adopts it, and a home belongs to one login at a time: a second home
275
+ for a login another one already holds -- a `.credentials.json` copied to another `CLAUDE_CONFIG_DIR` --
276
+ and a home whose active login has since moved are both refused with the importer's own code, so the
277
+ submit is known to have decided, the ledger row stays `verified` and no home changed.
278
+
192
279
  `import submit` takes the source id and the digest the owner read in the inventory. The daemon re-reads the
193
280
  source itself and refuses if the digest changed, so approving one source can never import a different one;
194
281
  no credential crosses the socket for a source this package can read. It refuses while an authswitch watch, a
@@ -196,7 +283,13 @@ native harness process or Codex's app-server is running, and an app-server it ca
196
283
  An adopt whose refresh fails, or whose answer is lost, is never retried: the source is quarantined, its grant
197
284
  is left needing re-authentication, and the command says so. A projection fails differently, because nothing
198
285
  rotating was ever sent for it: a native store that could not be proven live stays a pending native owner and
199
- is imported by submitting the same source again once its own tool has refreshed it.
286
+ is imported by submitting the same source again once its own tool has refreshed it. That refresh rewrites the
287
+ file, so the retry carries the digest the inventory prints afterwards: a row waiting for its native owner
288
+ follows its own tool's bytes, and nothing else does. What it never follows is another login. A row names the
289
+ account and the grant it was approved for, and a source that now holds a different account's login is refused
290
+ with that stated -- before the account is registered, because the provider read that proves a projection asks
291
+ about the identity the new bytes themselves carry and would prove the wrong login perfectly well. Neither
292
+ that refusal nor "already imported" ever clears: no route forgets a row or re-points it at another account.
200
293
 
201
294
  `import status` reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per
202
295
  source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same
@@ -210,6 +303,17 @@ An import deletes nothing: retiring the legacy files is a separate, later step.
210
303
  deliberately not backed up, so losing it costs one device sign-in per account -- and the remote-control
211
304
  enrollments and runtime bindings with it.
212
305
 
306
+ **Where a daemon reads those stores is stated by whoever builds it, never defaulted.** The legacy stores are
307
+ this user's real credentials, and every location they are found at comes from the environment, so
308
+ `AuthSwitchAuthorityDaemon` takes a required `legacyImport`: either the locations to read (an `env` and an
309
+ absolute `homeDirectory`, plus the provider operations that import uses) or `'none'`, which builds a daemon
310
+ with no account import at all and answers `import inventory`, `import status` and `import submit` with the
311
+ `import_refusal` code -- a decided refusal of the closed set, never an empty inventory and never a fault, so
312
+ a caller reads it exactly as it reads every other import refusal. A construction that names neither -- no
313
+ locations and no `'none'` -- is refused before the daemon exists, rather than reading whichever host happened
314
+ to start it. The per-user daemon this package installs -- `authswitch authority daemon`, the service unit's
315
+ own entry point -- is the one place that takes those locations from this host.
316
+
213
317
  `AuthSwitchClient.getUsage(accountId, loginId)` reads OpenAI managed limits or a Claude
214
318
  host login's profile and usage through the management socket. The tagged, credential-free
215
319
  reading preserves Claude's labelled five-hour, weekly and model windows, plan provenance,
@@ -235,7 +339,7 @@ generated prose and credentials are absent from persistence and management respo
235
339
  removal and targeted reauthentication refuse while that account has an active preuse request.
236
340
 
237
341
  A trusted in-process daemon host can call `AuthSwitchAuthorityDaemon.startManagedCodex()`
238
- for a bound account and workspace. It owns a private Codex 0.155.1 app-server, passes only
342
+ for a bound account and workspace. It owns a private Codex 0.157.0 app-server, passes only
239
343
  the authority's current access token through Codex's external-token login, answers its
240
344
  unauthorized callback through the authority, and revokes the binding on stop. The returned
241
345
  runtime exposes an interactive `codex --remote unix://…` launch descriptor and native
@@ -251,15 +355,27 @@ gone; an uncertain launch remains blocked. Callers must explicitly stop owned pr
251
355
  after their work finishes. Once reauthentication starts, it holds new admissions until the
252
356
  device operation settles. Only the private socket directory is removed on close. Codex's
253
357
  credential store remains ephemeral and does not read or write `auth.json` in that home.
358
+ Codex binds the app-server socket in its own per-user directory,
359
+ `<realpath /tmp>/codex-daemon-<euid>`, and publishes the private socket path as a link to it.
360
+ The daemon checks that publication with `@modelprofile.com/mcp-crossharness`'s
361
+ `observeCodexUnixSocket` and connects only when the link is exactly Codex's own, in a
362
+ directory other users cannot rewrite, and the socket behind it is the user's own in a 0700
363
+ directory of the user's; anything else fails the start with that package's error. Codex keeps a
364
+ zero-byte startup lock in that directory for each socket path it has served; the file is
365
+ Codex's own and authswitch does not remove it.
366
+ Managed Codex runs only the verified Codex release: a `codex` that reports any other
367
+ version is refused before it starts, so an automatic Codex update stops new managed
368
+ sessions until authswitch has verified that release.
254
369
  Managed-Codex process ownership observation is qualified on Linux and reads exact argument
255
370
  boundaries from `/proc`; on other hosts doctor reports the process observation as unknown,
256
371
  and authority actions that require survivor proof refuse instead of guessing.
257
- Codex 0.155.1 does not persist an unused thread created by `thread/start`; resume applies
372
+ Codex 0.157.0 does not persist an unused thread created by `thread/start`; resume applies
258
373
  after Codex has committed a rollout. The existing CLI has not yet been moved to this path.
259
374
 
260
375
  This SDK is additive at this stage. The commands documented below still use the existing
261
- credential stores. The authority daemon does not import those stores automatically,
262
- replace their active native sessions, or take over their refresh grants. An active
376
+ credential stores, except that the legacy fence below refuses them a login the authority
377
+ holds. The authority daemon does not import those stores automatically, replace their
378
+ active native sessions, or take over their refresh grants. An active
263
379
  Codex/OpenCode native grant remains pending until its owner is idle and an explicit
264
380
  handoff verifies the latest native source. Claude Code's own native credential files
265
381
  remain vendor-owned; a container setup-token grant is a separate grant under the same
@@ -290,6 +406,62 @@ restore system could have written.
290
406
  The store is deliberately not backed up: its refresh grants are sealed to this host's TPM,
291
407
  so a lost store is recovered by one device re-login per account.
292
408
 
409
+ ### The legacy fence
410
+
411
+ Once the authority has taken a saved copy over, the legacy `save` and `switch` must not write it again: an
412
+ adopted copy's refresh token was rotated away by the import, and activating it would hand a tool a dead
413
+ login. `runAuthSwitchMutation` -- the one path the command line, the guide, `watch` and the hosted
414
+ `AuthSwitchService` all take -- asks first, and decides in three branches.
415
+
416
+ - **The authority answers**: it is asked for this record's ledger row through `import status` on the
417
+ management socket, keyed by the same source id the importer uses (a hash of the saved copy's path). A
418
+ `verified` or `complete` row refuses with `authority_holds_login`; any other row, or none, lets the command
419
+ run. An authority that answers decides, whether or not its service unit is installed.
420
+ - **Nothing answers within four seconds -- no socket, a silent one, or no `XDG_RUNTIME_DIR` -- and the
421
+ authority's service unit is installed**: the command refuses with `authority_unavailable` and the
422
+ instruction to run `authswitch authority service start`. Unknown is never treated as idle.
423
+ - **Nothing answers and no unit is installed**: the legacy commands behave exactly as before. This is every
424
+ host before the cutover.
425
+
426
+ What tells the second branch from the third is durable, never the socket: the runtime directory is tmpfs,
427
+ so after a reboot the socket is absent until the user service starts -- exactly when a fence keyed on it
428
+ would open. The evidence is the unit `authswitch authority service install` writes,
429
+ `$XDG_DATA_HOME/systemd/user/authswitch-authority.service` (default
430
+ `~/.local/share/systemd/user/authswitch-authority.service`), read through smartdaemon's
431
+ `SystemdUnitFile.inspect()`. The installer and the fence derive that directory from one rule,
432
+ `$XDG_DATA_HOME` or else the home directory; the installer takes both from its own process and its account's
433
+ home, the fence from the locations its caller states. The two agree unless `$HOME` is overridden while
434
+ `XDG_DATA_HOME` is unset, and then every store the fence guards lies under that overridden home too. The fence
435
+ writes nothing and keeps no record of its own.
436
+
437
+ The check works without a login session. Reading the unit needs only its directory, so a cron job or a plain
438
+ ssh command with no `XDG_RUNTIME_DIR` is decided by the unit exactly like a desktop session: no unit, the
439
+ legacy commands run; the unit installed, they refuse with `authority_unavailable`. The stated
440
+ `XDG_RUNTIME_DIR` names only the socket to ask; an absent one is never replaced by this process's own. The
441
+ answer depends on the unit directory derived from the stated `XDG_DATA_HOME` or home, and on nothing else:
442
+ a malformed `XDG_RUNTIME_DIR` or `XDG_CONFIG_HOME` never refuses the command, and neither does a malformed
443
+ process `XDG_DATA_HOME` when the caller states other locations. A unit that cannot be inspected -- a file the
444
+ installer would not have written, a directory another user can write -- refuses rather than being read as
445
+ absent, and so does a stated `XDG_DATA_HOME` that names no usable unit directory, because the reader can
446
+ then tell an installed authority from none no better. On a platform without systemd no unit can be
447
+ installed.
448
+
449
+ The unit says an authority exists, not which stores it imports from, so while an installed authority is
450
+ stopped the legacy save and switch refuse for every saved copy of every store, until it runs again. A
451
+ record the importer could never have read -- an account id that names no saved-copy file -- is never asked
452
+ about.
453
+
454
+ `drop` / `rm` and the `remove` mutation are deliberately not fenced: they delete the user's own saved copy
455
+ and write no credential. A projected source is never fenced either -- its native store stays with its own
456
+ tool. The command line takes this host's locations only when it composes its own harnesses, and with
457
+ injected harnesses is fenced only by locations its caller states; `AuthSwitchOperations`,
458
+ `runAuthSwitchMutation` and an `AuthSwitchService` given its harnesses take the locations as a required
459
+ argument (`authSwitchHostLegacyFence()` names this host's, `'none'` states that a
460
+ caller owns no authority), so no library path reads a host it was not given. `AuthSwitchOperations` and the
461
+ hosted service ask the fence before their AGL coordinator as well, so a refused mutation never has its host
462
+ stop the runtimes it would restart. The hosted service reports a fence refusal as the operation's problem, in
463
+ the instruction's own words.
464
+
293
465
  ## Usage
294
466
 
295
467
  Run `authswitch`, `authswitch -i`, or `authswitch --interactive` for an interactive guide. Use the arrow keys and
@@ -452,9 +624,12 @@ login. `activateCredential(providerId, credential)` preserves and verifies the
452
624
  outgoing native login before activation; the caller retains ownership of the
453
625
  incoming credential's durable storage. Both are backend APIs, never wire payloads.
454
626
 
455
- `AuthSwitchOperations` is shared by the command, guide and TUI. Its optional
456
- coordinator receives only the harness, operation, account ID, consent to wait,
457
- and a credential-location fingerprint. AGL owns private-controller discovery;
627
+ `AuthSwitchOperations` is shared by the command, guide and TUI, and is built
628
+ with a coordinator (or `undefined`) and the legacy fence locations described in
629
+ "The legacy fence". A host that injects its own harnesses into `AuthSwitchService`
630
+ states that fence as the third argument. The coordinator receives only the
631
+ harness, operation, account ID, consent to wait, and a credential-location
632
+ fingerprint. AGL owns private-controller discovery;
458
633
  authswitch uses `agl authswitch --request <json>` without an AGL package dependency.
459
634
  A failed or ambiguous transport never falls through to a second local mutation.
460
635
  AGL and the CLI must use matching credential locations and compatible versions.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '8.1.0',
6
+ version: '9.0.0',
7
7
  description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
@@ -2,6 +2,106 @@ import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
2
  import type { IAuthSwitchUsageSnapshot } from './classes.authorityusage.js';
3
3
  export type { IAuthSwitchUsageSnapshot } from './classes.authorityusage.js';
4
4
 
5
+ /**
6
+ * Why the authority declined, as a closed set a client branches on without reading the text.
7
+ *
8
+ * A refusal is an answer: the daemon did the check, nothing was left half-done, and the message says what
9
+ * the owner does instead. Everything else stays a fault whose text the transport replaces, and a caller
10
+ * must never present a fault as if it said what to do next.
11
+ */
12
+ export type TAuthSwitchRefusalCode =
13
+ | 'authority_closing'
14
+ | 'not_found'
15
+ | 'account_changed'
16
+ | 'account_busy'
17
+ | 'login_unavailable'
18
+ /** A runtime capability that is missing, wrong or superseded: deliberately one answer, and one repair. */
19
+ | 'binding_unauthorized'
20
+ /** The authority holds this login, so the legacy path that used to move it no longer may. */
21
+ | 'authority_holds_login'
22
+ /** An authority is installed on this host and is not answering, so nothing may assume it holds nothing. */
23
+ | 'authority_unavailable'
24
+ | 'native_owner_holds_login'
25
+ | 'claude_home_unregistered'
26
+ | 'claude_handoff_pending'
27
+ | 'claude_receipt_missing'
28
+ | 'import_refusal';
29
+
30
+ /** The marker the importer has published since 8.1.0; it keeps its own value on the wire. */
31
+ export const authSwitchImportRefusalMarker = 'authswitch_import_refusal';
32
+ export const authSwitchRefusalReason = 'authswitch_refusal';
33
+
34
+ export type TAuthSwitchRefusalReason = typeof authSwitchRefusalReason | typeof authSwitchImportRefusalMarker;
35
+
36
+ /** Everything a refusal puts on the wire beside its message: two literals, never a value of its own. */
37
+ export interface IAuthSwitchRefusalData {
38
+ reason: TAuthSwitchRefusalReason;
39
+ code: TAuthSwitchRefusalCode;
40
+ }
41
+
42
+ /** What the owner is told when the answer is an instruction rather than a fault. */
43
+ export interface IAuthSwitchRefusal {
44
+ code: TAuthSwitchRefusalCode;
45
+ /** The instruction, authored by the handler that declined. */
46
+ instruction: string;
47
+ }
48
+
49
+ const refusalCodes: ReadonlySet<string> = new Set<TAuthSwitchRefusalCode>([
50
+ 'authority_closing', 'not_found', 'account_changed', 'account_busy',
51
+ 'login_unavailable', 'binding_unauthorized', 'native_owner_holds_login', 'claude_home_unregistered',
52
+ 'claude_handoff_pending', 'claude_receipt_missing', 'import_refusal',
53
+ 'authority_holds_login', 'authority_unavailable',
54
+ ]);
55
+
56
+ const isRefusalCode = (value: unknown): value is TAuthSwitchRefusalCode =>
57
+ typeof value === 'string' && refusalCodes.has(value);
58
+
59
+ /** The importer keeps the marker it published; every other code shares the authority's own. */
60
+ const reasonOf = (code: TAuthSwitchRefusalCode): TAuthSwitchRefusalReason =>
61
+ code === 'import_refusal' ? authSwitchImportRefusalMarker : authSwitchRefusalReason;
62
+
63
+ /**
64
+ * One refusal, thrown by a handler and turned into a marked answer by the router that owns the wire.
65
+ *
66
+ * It accepts nothing but a code of the closed set and the text the owner is to read, and builds the marker
67
+ * from the code alone, so no handler can attach a value of its own to what a client branches on.
68
+ */
69
+ export class AuthSwitchRefusal extends Error {
70
+ public readonly code: TAuthSwitchRefusalCode;
71
+
72
+ constructor(code: TAuthSwitchRefusalCode, instruction: string) {
73
+ super(instruction);
74
+ this.code = code;
75
+ }
76
+
77
+ /** Everything this refusal puts on the wire beside its message. */
78
+ public get data(): IAuthSwitchRefusalData { return { reason: reasonOf(this.code), code: this.code }; }
79
+ }
80
+
81
+ /**
82
+ * The refusal an error answer carries, or null when it is a fault whose text says nothing.
83
+ *
84
+ * This is the only reader of `errorData` in the package: the importer's published predicate is expressed
85
+ * through it, so a caller never has to know which of the two markers a route uses.
86
+ *
87
+ * 8.1.0 marked the importer's refusal with the reason alone. A per-user daemon keeps running across an
88
+ * upgrade until its owner restarts it, so a newer client still meets that payload, and it names the one
89
+ * refusal that release could answer with -- which is why it is read rather than treated as a fault.
90
+ */
91
+ export const asAuthSwitchRefusal = (error: unknown): IAuthSwitchRefusal | null => {
92
+ if (error instanceof AuthSwitchRefusal) return { code: error.code, instruction: error.message };
93
+ if (typeof error !== 'object' || error === null || !('errorData' in error)) return null;
94
+ const data: unknown = error.errorData;
95
+ if (typeof data !== 'object' || data === null || !('reason' in data)) return null;
96
+ const code: unknown = 'code' in data ? data.code : undefined;
97
+ const instruction = 'message' in error && typeof error.message === 'string' ? error.message : '';
98
+ if (data.reason === authSwitchImportRefusalMarker) {
99
+ return code === undefined || code === 'import_refusal' ? { code: 'import_refusal', instruction } : null;
100
+ }
101
+ if (data.reason !== authSwitchRefusalReason || !isRefusalCode(code)) return null;
102
+ return { code, instruction };
103
+ };
104
+
5
105
  /** Credential-free account management contract. Safe to import in browser code. */
6
106
  export type TAuthSwitchLoginPurpose = 'openai_managed' | 'claude_host_native' | 'claude_container_setup' | 'opencode_native';
7
107
  export type TAuthSwitchLoginHealth = 'ready' | 'refreshing' | 'retry_wait' | 'needs_reauth' | 'unverified' | 'pending_handoff' | 'handoff_quarantined' | 'removed';
@@ -17,6 +117,16 @@ export interface IAuthSwitchAccount {
17
117
  statusObservedAt: string;
18
118
  }
19
119
 
120
+ /**
121
+ * Which tool refreshes a natively owned login, named rather than inferred.
122
+ *
123
+ * `owner` says that a native tool holds the login; this says which one, so a consumer presents "Claude Code"
124
+ * or "Codex" without deriving it from `purpose` -- a derivation that would be wrong the moment two purposes
125
+ * share a tool, and that belongs to the side that decides ownership in the first place. It is `null` exactly
126
+ * when no native tool refreshes the login: the authority does, or nothing does.
127
+ */
128
+ export type TAuthSwitchLoginOwnerTool = 'claude_code' | 'codex' | 'opencode';
129
+
20
130
  /** One independently owned login. Native ownership does not imply observed health. */
21
131
  export interface IAuthSwitchLogin {
22
132
  id: string;
@@ -24,6 +134,8 @@ export interface IAuthSwitchLogin {
24
134
  providerId: string;
25
135
  purpose: TAuthSwitchLoginPurpose;
26
136
  owner: 'daemon' | 'claude_native' | 'legacy_native' | 'none';
137
+ /** The native tool that refreshes this login, or null when the authority or nobody does. */
138
+ ownerTool: TAuthSwitchLoginOwnerTool | null;
27
139
  health: TAuthSwitchLoginHealth;
28
140
  problem: 'none' | 'provider_unavailable' | 'exchange_uncertain' | 'provider_rejected' | 'native_owner';
29
141
  grantGeneration: number;
@@ -234,6 +346,17 @@ export interface IAuthSwitchPreuseOperation {
234
346
  revision: number;
235
347
  }
236
348
 
349
+ /**
350
+ * Why the host could not prove that Claude Code uses its own native login.
351
+ *
352
+ * Each value names one condition of that proof, so an owner is told what to change: install the supported
353
+ * release, sign in to a subscriber plan, remove a credential override or a settings source, let the
354
+ * profile read succeed, or close the running session.
355
+ */
356
+ export type TAuthSwitchClaudeProofFailure =
357
+ | 'unsupported_release' | 'override' | 'profile' | 'profile_unreadable'
358
+ | 'settings' | 'subscription' | 'running_session';
359
+
237
360
  /** Credential-free native file ownership result. It makes no claim about a running session's active request. */
238
361
  export interface IAuthSwitchClaudeNativeHandoff {
239
362
  id: string;
@@ -243,6 +366,8 @@ export interface IAuthSwitchClaudeNativeHandoff {
243
366
  phase: 'reserved' | 'prepared' | 'committed' | 'aborted' | 'quarantined';
244
367
  problem: 'none' | 'native_uncertain' | 'foreign_or_torn' | 'unsupported_effective_auth' | 'database_uncertain';
245
368
  runningEffectiveAuth: 'no_scoped_sessions' | 'unsupported_effective_auth' | null;
369
+ /** Which condition of the native-login proof failed, when `problem` is `unsupported_effective_auth`. */
370
+ proofFailure?: TAuthSwitchClaudeProofFailure;
246
371
  updatedAt: string;
247
372
  }
248
373
 
@@ -1,4 +1,5 @@
1
1
  import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
+ import { asAuthSwitchRefusal, authSwitchImportRefusalMarker } from './authority-contract.js';
2
3
 
3
4
  /**
4
5
  * The one-time account import: reading the legacy stores that existed before the authority, and the
@@ -148,20 +149,20 @@ export type TAuthSwitchImportAction = 'none' | 'resume' | 'device_login';
148
149
  /**
149
150
  * Marks an error answer whose text is an instruction for the owner, not a report of a fault.
150
151
  *
151
- * The daemon sets it on the refusals the importer authors and on nothing else. Every other failure answers
152
- * with the transport's own sanitised text, which a caller must never present as if it said what to do next --
153
- * for a submit in particular, an unmarked failure means the outcome is unknown and the source must be read
154
- * with `authswitch.authority.import.status` rather than submitted again.
152
+ * The daemon sets it on the refusals the importer authors and on nothing else: a refusal any other route
153
+ * authors carries the authority's own marker, and a failure neither of them decided answers with the
154
+ * transport's sanitised text, which a caller must never present as if it said what to do next. For a
155
+ * submit in particular, an unmarked failure means the outcome is unknown and the source must be read with
156
+ * `authswitch.authority.import.status` rather than submitted again.
157
+ *
158
+ * It is one code of the authority's refusal contract (`./authority-contract`), which every route now uses;
159
+ * this marker keeps its own value on the wire so a consumer written against 8.1.0 keeps working.
155
160
  */
156
- export const authSwitchImportRefusalReason = 'authswitch_import_refusal';
161
+ export const authSwitchImportRefusalReason = authSwitchImportRefusalMarker;
157
162
 
158
163
  /** True when the daemon answered with an importer refusal, so `error.message` is the instruction to show. */
159
- export const isAuthSwitchImportRefusal = (error: unknown): boolean => {
160
- if (typeof error !== 'object' || error === null || !('errorData' in error)) return false;
161
- const data = error.errorData;
162
- return typeof data === 'object' && data !== null && 'reason' in data
163
- && data.reason === authSwitchImportRefusalReason;
164
- };
164
+ export const isAuthSwitchImportRefusal = (error: unknown): boolean =>
165
+ asAuthSwitchRefusal(error)?.code === 'import_refusal';
165
166
 
166
167
  /**
167
168
  * Where one source stands, read from the migration ledger, the grant it produced and its handoff.
@@ -0,0 +1,69 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * Where this user's authority lives: its sockets, its store and the service unit that runs it.
5
+ *
6
+ * These rules sit in a leaf module because two sides read them. `AuthSwitchAuthorityService` installs and
7
+ * runs the daemon there, and the legacy fence (`./classes.legacyfence`) asks the same socket and looks for
8
+ * the same unit -- without importing the daemon, whose module graph reaches back to the legacy operations
9
+ * the fence guards.
10
+ */
11
+
12
+ export interface IAuthSwitchAuthorityPaths {
13
+ runtimeDirectory: string;
14
+ dataDirectory: string;
15
+ databaseSocketPath: string;
16
+ authoritySocketPath: string;
17
+ runtimeSocketPath: string;
18
+ runtimeSocketDirectory: string;
19
+ }
20
+
21
+ /** The authority's sockets. */
22
+ export type TAuthSwitchAuthoritySocketPaths = Omit<IAuthSwitchAuthorityPaths, 'dataDirectory'>;
23
+
24
+ /**
25
+ * The sockets under one runtime directory, derived from it alone.
26
+ *
27
+ * The legacy fence asks the socket a caller states and must not depend on anything this process holds --
28
+ * its home or its `XDG_DATA_HOME`, which only the store's directory needs.
29
+ */
30
+ export const authSwitchAuthoritySocketPaths = (
31
+ runtimeDirectory: string | undefined): TAuthSwitchAuthoritySocketPaths => {
32
+ if (!runtimeDirectory || !plugins.path.isAbsolute(runtimeDirectory)) {
33
+ throw new Error('A private XDG_RUNTIME_DIR is required for authswitch authority.');
34
+ }
35
+ const socketDirectory = plugins.path.join(runtimeDirectory, 'authswitch');
36
+ const runtimeSocketDirectory = plugins.path.join(socketDirectory, 'runtime');
37
+ return {
38
+ runtimeDirectory,
39
+ databaseSocketPath: plugins.path.join(socketDirectory, 'internal', 'db.sock'),
40
+ authoritySocketPath: plugins.path.join(socketDirectory, 'management', 'authority.sock'),
41
+ runtimeSocketPath: plugins.path.join(runtimeSocketDirectory, 'runtime.sock'),
42
+ runtimeSocketDirectory,
43
+ };
44
+ };
45
+
46
+ /** Canonical per-user paths. An explicit runtime directory is also usable by a trusted container host. */
47
+ export const resolveAuthSwitchAuthorityPaths = (runtimeDirectory = process.env.XDG_RUNTIME_DIR): IAuthSwitchAuthorityPaths => {
48
+ const sockets = authSwitchAuthoritySocketPaths(runtimeDirectory);
49
+ const home = plugins.os.userInfo().homedir;
50
+ const dataHome = process.env.XDG_DATA_HOME || plugins.path.join(home, '.local/share');
51
+ if (!plugins.path.isAbsolute(dataHome)) throw new Error('XDG_DATA_HOME must be an absolute path.');
52
+ return { ...sockets, dataDirectory: plugins.path.join(dataHome, 'authswitch', 'authority') };
53
+ };
54
+
55
+ /** The user service `authswitch authority service install` writes. */
56
+ export const authSwitchAuthorityUnitName = 'authswitch-authority.service';
57
+
58
+ /**
59
+ * The directory that unit is installed into, from a stated environment and home directory.
60
+ *
61
+ * It is the rule smartdaemon applies to a user unit when it is given no directory --
62
+ * `$XDG_DATA_HOME/systemd/user`, default `~/.local/share/systemd/user` -- stated here so the installer passes
63
+ * it explicitly and the fence derives it from the locations its caller states rather than from this process.
64
+ */
65
+ export const authSwitchAuthorityUnitDirectory = (env: NodeJS.ProcessEnv, homeDirectory: string): string => {
66
+ const dataHome = env.XDG_DATA_HOME || plugins.path.join(homeDirectory, '.local/share');
67
+ if (!plugins.path.isAbsolute(dataHome)) throw new Error('XDG_DATA_HOME must be an absolute path.');
68
+ return plugins.path.join(dataHome, 'systemd/user');
69
+ };
@@ -1,7 +1,17 @@
1
1
  import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
2
  import type { IAuthSwitchBinding } from './authority-contract.js';
3
3
 
4
- /** Backend-only binding operation. The capability must never enter a browser-facing response. */
4
+ /**
5
+ * Backend-only binding operation, served on the MANAGEMENT socket. Not a misplacement -- the rule.
6
+ *
7
+ * The other two requests in this file are answered on the runtime socket, whose directory a container may
8
+ * be given. This one is not, because it mints the capability those two spend: minting is the act that
9
+ * authorizes a runtime, so it belongs to a caller already trusted with account management. A container
10
+ * holding the runtime directory can therefore use the binding it was handed, and can never create one --
11
+ * not for itself, and not for another account.
12
+ *
13
+ * The capability must never enter a browser-facing response.
14
+ */
5
15
  export interface IReq_AuthSwitchBindAccount extends ITypedRequest {
6
16
  method: 'authswitch.authority.bind';
7
17
  request: {