@modelprofile.com/authswitch 8.2.0 → 9.1.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 (64) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +84 -1
  3. package/dist_ts/authority-contract.js +14 -2
  4. package/dist_ts/authority-import-contract.d.ts +6 -0
  5. package/dist_ts/authority-paths.d.ts +37 -0
  6. package/dist_ts/authority-paths.js +46 -0
  7. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  8. package/dist_ts/classes.authoritybroker.d.ts +23 -8
  9. package/dist_ts/classes.authoritybroker.js +155 -32
  10. package/dist_ts/classes.authorityclient.d.ts +22 -2
  11. package/dist_ts/classes.authorityclient.js +98 -13
  12. package/dist_ts/classes.authoritydaemon.d.ts +21 -3
  13. package/dist_ts/classes.authoritydaemon.js +77 -32
  14. package/dist_ts/classes.authoritydatabase.d.ts +21 -3
  15. package/dist_ts/classes.authoritydatabase.js +98 -12
  16. package/dist_ts/classes.authorityimport.d.ts +16 -4
  17. package/dist_ts/classes.authorityimport.js +75 -24
  18. package/dist_ts/classes.authoritymodels.js +5 -3
  19. package/dist_ts/classes.authoritypreuse.js +8 -3
  20. package/dist_ts/classes.authorityservice.d.ts +10 -11
  21. package/dist_ts/classes.authorityservice.js +14 -23
  22. package/dist_ts/classes.cli.d.ts +10 -2
  23. package/dist_ts/classes.cli.js +12 -4
  24. package/dist_ts/classes.codexmanaged.d.ts +0 -1
  25. package/dist_ts/classes.codexmanaged.js +13 -26
  26. package/dist_ts/classes.legacyfence.d.ts +53 -0
  27. package/dist_ts/classes.legacyfence.js +189 -0
  28. package/dist_ts/classes.operations.d.ts +15 -3
  29. package/dist_ts/classes.operations.js +22 -4
  30. package/dist_ts/classes.service.d.ts +21 -2
  31. package/dist_ts/classes.service.js +35 -8
  32. package/dist_ts/classes.tui.d.ts +2 -1
  33. package/dist_ts/classes.tui.js +3 -2
  34. package/dist_ts/codexcontract.d.ts +30 -0
  35. package/dist_ts/codexcontract.js +174 -0
  36. package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
  37. package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
  38. package/dist_ts/ts_migration/index.js +3 -1
  39. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  40. package/package.json +11 -11
  41. package/readme.md +194 -24
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/authority-contract.ts +90 -4
  44. package/ts/authority-import-contract.ts +6 -0
  45. package/ts/authority-paths.ts +69 -0
  46. package/ts/authority-runtime-contract.ts +11 -1
  47. package/ts/classes.authoritybroker.ts +153 -33
  48. package/ts/classes.authorityclient.ts +102 -15
  49. package/ts/classes.authoritydaemon.ts +89 -27
  50. package/ts/classes.authoritydatabase.ts +98 -12
  51. package/ts/classes.authorityimport.ts +102 -25
  52. package/ts/classes.authoritymodels.ts +4 -1
  53. package/ts/classes.authoritypreuse.ts +7 -1
  54. package/ts/classes.authorityservice.ts +15 -30
  55. package/ts/classes.cli.ts +14 -3
  56. package/ts/classes.codexmanaged.ts +10 -19
  57. package/ts/classes.legacyfence.ts +219 -0
  58. package/ts/classes.operations.ts +22 -3
  59. package/ts/classes.service.ts +45 -8
  60. package/ts/classes.tui.ts +3 -1
  61. package/ts/codexcontract.ts +200 -0
  62. package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
  63. package/ts/ts_migration/index.ts +2 -0
  64. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
package/readme.md CHANGED
@@ -32,7 +32,23 @@ 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.
40
+ The snapshot also lists `nativeAssignments`: which account a vendor tool's own home on this host runs on,
41
+ as `{ id, tool, accountId, loginId, state, revision }` with the home named by its hash. Only homes the
42
+ authority itself switches are recorded -- Claude Code homes adopted by a verified import -- and `state` is
43
+ `switching` while a handoff to another account is in flight and `quarantined` until one is resolved. A
44
+ Codex or OpenCode store that its own tool still refreshes appears as its login instead, with
45
+ `owner: 'legacy_native'` and its `ownerTool`, because the authority does not decide what that store holds.
46
+ Every change of an assignment is an account event, so a subscriber sees it like any other change.
47
+ A removed account is left out of the snapshot unless the caller asks with `includeRemoved` (on `snapshot`,
48
+ `snapshotAll` and `subscribe`): it then appears with `removed: true` and its logins with `health: 'removed'`.
49
+ An account label follows one published rule, `isAuthSwitchAccountLabel` (at most
50
+ `authSwitchAccountLabelMaxLength` characters, no space at either end, no control character); a rename that
51
+ breaks it is refused with `invalid_input`.
36
52
 
37
53
  ```ts
38
54
  import { randomUUID } from 'node:crypto';
@@ -46,18 +62,30 @@ const operationId = randomUUID();
46
62
  const operation = await client.beginAddOpenAi(operationId);
47
63
  // Keep operation.id to discover its final result after navigation or reconnect.
48
64
  const receipt = await client.getOperation(operation.id);
65
+ // Or follow it without polling: each answer comes when the operation changes.
66
+ let current = operation;
67
+ while (['starting', 'pending', 'committing'].includes(current.state)) {
68
+ current = await client.watchOperation(current.id, current.revision);
69
+ }
49
70
  ```
50
71
 
51
72
  Browser code can import the credential-free DTOs from
52
73
  `@modelprofile.com/authswitch/authority-contract`. The backend-only binding, access and release
53
74
  requests are named by `@modelprofile.com/authswitch/authority-runtime-contract`, and the one-time
54
- import's own DTOs by `@modelprofile.com/authswitch/authority-import-contract`; the runtime contract
55
- belongs to the runtime socket and its capability must never reach a browser. A backend binds an
56
- account to a runtime incarnation by exact account ID, login ID and purpose, and keeps the returned
57
- capability private. Targeted reauthentication likewise requires the exact login; an
58
- account's presentation default never selects a grant for either action. Completed and
75
+ import's own DTOs by `@modelprofile.com/authswitch/authority-import-contract`; those capabilities must
76
+ never reach a browser. Two of the three runtime requests are served on the runtime socket -- resolving
77
+ access and releasing a binding -- while `authswitch.authority.bind` is served on the **management** socket
78
+ by design: minting a capability is the act that authorizes a runtime, so only a trusted management caller
79
+ may perform it, and a container that is given the runtime directory can use a binding it was handed but can
80
+ never create one. A backend binds an account to a runtime incarnation by exact account ID, login ID and
81
+ purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
82
+ exact login; an account's presentation default never selects a grant for either action. Completed and
59
83
  interrupted operations remain discoverable through `getOperation()` and paged
60
- `listOperations()`. A completed receipt and its account/grant change commit together, so
84
+ `listOperations()`. `watchOperation(id, afterRevision, waitMs = 30000)` is the same read as a long poll,
85
+ the way `events` waits: it answers as soon as the operation's revision passes `afterRevision` -- a sign-in
86
+ answers `starting` with no prompt, then `pending` with its device prompt, then its outcome -- at once for a
87
+ finished operation, and with the unchanged operation once `waitMs` (at most 30000) runs out. Only a change
88
+ of that operation answers it. A completed receipt and its account/grant change commit together, so
61
89
  the receipt resolves a lost response or an event that arrives first. Add and reauthentication
62
90
  callers supply one UUID for the logical start and reuse it after a lost response. Replaying that
63
91
  UUID returns the same matching receipt without starting another provider login; using it for a
@@ -68,16 +96,29 @@ resolves that binding to a current access token; its directory can be mounted in
68
96
  container without exposing the management socket. A provider that rejects a specific
69
97
  access-token generation can request a newer one through `rejectedGrantGeneration`; the
70
98
  authority coalesces concurrent rejection callbacks and never replays an uncertain refresh.
71
- After an external Flex or OpenCode runtime has fenced new work and drained its pending access
72
- resolutions and provider requests, it can release its exact binding capability through the
73
- runtime socket. Every bind receives a fresh random capability, so a stale release cannot fence
99
+ A ChatGPT login backs a Claude Code runtime too (`runtime: 'claude'`); a Claude account never binds,
100
+ because it reaches Claude Code through the native switch. After an external Flex, OpenCode or Claude
101
+ runtime has fenced new work and drained its pending access resolutions and provider requests, it can
102
+ release its exact binding capability through the runtime socket. Every bind receives a fresh random capability, so a stale release cannot fence
74
103
  or delete a successor even if the caller reused an incarnation label. Release waits for admitted
75
104
  server-side resolution handlers, but handler completion does not prove response delivery and
76
105
  cannot retract an access token already received or in use; the runtime owner therefore owns that
77
106
  drain. Generic release cannot revoke a managed Codex binding, whose daemon-owned stop still closes
78
107
  the runtime and clears its durable run before internal revocation. Binding release never transfers
79
- or changes ownership of the account grant. External-binding recovery after an owner crash remains
80
- a prerequisite for the full account-mutation cutover.
108
+ or changes ownership of the account grant. A holder that crashed and lost its capability recovers
109
+ its binding without a new route: the snapshot (and `getBinding`) publishes each binding's runtime and
110
+ scope, binding that runtime and scope again replaces the capability, and releasing the returned
111
+ capability releases the binding. If binding again is refused because the account needs a new sign-in,
112
+ reauthenticate it first.
113
+
114
+ `getBinding(bindingId)` (`authswitch.authority.binding`) reads one binding by the id `bind` returned,
115
+ credential-free, or `null` once the authority holds none by that id, so a backend checks its binding without
116
+ reading the whole snapshot.
117
+
118
+ Removing an account that still backs a Flex, OpenCode or Claude binding is refused with `account_busy`, naming
119
+ those runtimes: their holder stops them and releases the bindings first, so a removal never strands a
120
+ runtime whose next access would fail. Managed Codex bindings are the daemon's own; removal drains managed
121
+ Codex as before and then takes them with the account.
81
122
 
82
123
  Every route on either socket answers in one of three ways: a result, a **refusal**, or a fault. A refusal
83
124
  is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
@@ -91,9 +132,29 @@ instruction for an owner.
91
132
  that marker: it answers `{ code, instruction }` for a refusal and `null` for a fault. The code is the
92
133
  closed `TAuthSwitchRefusalCode` set, which is what a client branches on without reading the text:
93
134
  `authority_closing`, `not_found`, `account_changed`, `account_busy`, `login_unavailable`,
94
- `native_owner_holds_login`, `claude_home_unregistered`, `claude_handoff_pending`,
95
- `claude_receipt_missing` and `import_refusal`. The instruction is authored for the owner and contains no
96
- path, credential or digest; only an importer refusal may name the process the owner has to stop.
135
+ `binding_unauthorized`, `native_owner_holds_login`, `claude_home_unregistered`, `claude_handoff_pending`,
136
+ `claude_receipt_missing`, `import_refusal`, `codex_unsupported`, `invalid_input`, `login_needs_reauth` and
137
+ `access_not_fresh`, plus the two the legacy commands answer with on this side of
138
+ the socket, `authority_holds_login` and `authority_unavailable` (see "The legacy fence" below). The
139
+ instruction is authored for the owner and contains no path, credential or digest; only an importer refusal
140
+ may name the process the owner has to stop. The set is producer-owned: a consumer branches on the codes it
141
+ knows and keeps a default branch, which is what lets the authority name a new one without breaking it.
142
+
143
+ The runtime's own answers are named the same way. `authswitch.authority.bind` refuses `login_unavailable`
144
+ when the authority does not hold the login itself, and `authswitch.authority.resolveAccess` refuses
145
+ `binding_unauthorized` for a capability that authorizes nothing -- missing, wrong, or superseded while the
146
+ credential was being resolved. Those are one answer on purpose: an absent binding and a wrong capability are
147
+ compared against the same zero hash, so nothing distinguishes them, and the repair is the same either way --
148
+ bind again. A binding whose account has since been reauthorized reads `login_unavailable` instead, because
149
+ binding again cannot help until that account holds a login again. A device sign-in or preuse operation this
150
+ authority does not hold reads `not_found`, from whichever route met it. What the access loop decides about
151
+ the login itself is marked as well: `login_needs_reauth` when only a new device sign-in brings the login back
152
+ (after which a bound runtime binds again), and `access_not_fresh` when the login is intact but the provider
153
+ could not renew its access yet, so the same request can succeed later. A changed identity or a view that
154
+ keeps moving during resolution stays an unmarked fault, because nobody decided it. Binding an *account* this
155
+ authority does not hold at all stays one too: the storage layer throws `Account is not available for this runtime.`,
156
+ which a socket caller reads as `Internal server error`, because naming it is a decision about which layer
157
+ authors owner instructions rather than a wording fix.
97
158
 
98
159
  The import routes keep the marker they published in 8.1.0, `authswitch_import_refusal`, and
99
160
  `isAuthSwitchImportRefusal` from `@modelprofile.com/authswitch/authority-import-contract` is the same
@@ -104,6 +165,15 @@ and the ledger row it wrote says where that source stands -- while an unmarked f
104
165
  unknown, and that source must then be read with `authswitch.authority.import.status` rather than
105
166
  submitted again.
106
167
 
168
+ A daemon that is shutting down admits no new request -- every route answers `authority_closing` -- and
169
+ settles the requests it already admitted before it closes its store: a waiting `events` or
170
+ `watchOperation` read answers normally from the open store instead of failing.
171
+
172
+ A client reading a daemon that runs an older release -- one not restarted after an upgrade -- fails with
173
+ `AuthSwitchDaemonOutdatedError`, which names the restart (`authswitch authority service stop`, then
174
+ `start`), and a subscription reports `unavailable` with the reason `daemon_outdated` once and retries every
175
+ five seconds until the daemon is restarted.
176
+
107
177
  `AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus })` reports `current`
108
178
  after a fresh snapshot or verified heartbeat, `unavailable` on disconnect or resync, and
109
179
  `closed` on abort. Keep cached account actions disabled while status is not `current`.
@@ -259,11 +329,19 @@ native harness process or Codex's app-server is running, and an app-server it ca
259
329
  An adopt whose refresh fails, or whose answer is lost, is never retried: the source is quarantined, its grant
260
330
  is left needing re-authentication, and the command says so. A projection fails differently, because nothing
261
331
  rotating was ever sent for it: a native store that could not be proven live stays a pending native owner and
262
- is imported by submitting the same source again once its own tool has refreshed it.
332
+ is imported by submitting the same source again once its own tool has refreshed it. That refresh rewrites the
333
+ file, so the retry carries the digest the inventory prints afterwards: a row waiting for its native owner
334
+ follows its own tool's bytes, and nothing else does. What it never follows is another login. A row names the
335
+ account and the grant it was approved for, and a source that now holds a different account's login is refused
336
+ with that stated -- before the account is registered, because the provider read that proves a projection asks
337
+ about the identity the new bytes themselves carry and would prove the wrong login perfectly well. Neither
338
+ that refusal nor "already imported" ever clears: no route forgets a row or re-points it at another account.
263
339
 
264
340
  `import status` reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per
265
341
  source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same
266
- source again, or sign in to that account again. Submitting a source again is how an interrupted run resumes,
342
+ source again, or sign in to that account again. Each entry also carries the source's `sourcePathHash`, so a
343
+ backend that submitted an external source finds its own record by `sourceKind` and that hash -- also after a
344
+ submit whose outcome it never learned -- without deriving the ledger row id. Submitting a source again is how an interrupted run resumes,
267
345
  and the answer comes from the same durable record the daemon decides on: a source whose sign-in was in
268
346
  flight when the run died is reported as needing a device sign-in, not as resumable, because a rotating
269
347
  refresh token is never sent a second time. Sources that were never submitted have no ledger row and appear
@@ -273,6 +351,17 @@ An import deletes nothing: retiring the legacy files is a separate, later step.
273
351
  deliberately not backed up, so losing it costs one device sign-in per account -- and the remote-control
274
352
  enrollments and runtime bindings with it.
275
353
 
354
+ **Where a daemon reads those stores is stated by whoever builds it, never defaulted.** The legacy stores are
355
+ this user's real credentials, and every location they are found at comes from the environment, so
356
+ `AuthSwitchAuthorityDaemon` takes a required `legacyImport`: either the locations to read (an `env` and an
357
+ absolute `homeDirectory`, plus the provider operations that import uses) or `'none'`, which builds a daemon
358
+ with no account import at all and answers `import inventory`, `import status` and `import submit` with the
359
+ `import_refusal` code -- a decided refusal of the closed set, never an empty inventory and never a fault, so
360
+ a caller reads it exactly as it reads every other import refusal. A construction that names neither -- no
361
+ locations and no `'none'` -- is refused before the daemon exists, rather than reading whichever host happened
362
+ to start it. The per-user daemon this package installs -- `authswitch authority daemon`, the service unit's
363
+ own entry point -- is the one place that takes those locations from this host.
364
+
276
365
  `AuthSwitchClient.getUsage(accountId, loginId)` reads OpenAI managed limits or a Claude
277
366
  host login's profile and usage through the management socket. The tagged, credential-free
278
367
  reading preserves Claude's labelled five-hour, weekly and model windows, plan provenance,
@@ -298,7 +387,7 @@ generated prose and credentials are absent from persistence and management respo
298
387
  removal and targeted reauthentication refuse while that account has an active preuse request.
299
388
 
300
389
  A trusted in-process daemon host can call `AuthSwitchAuthorityDaemon.startManagedCodex()`
301
- for a bound account and workspace. It owns a private Codex 0.155.1 app-server, passes only
390
+ for a bound account and workspace. It owns a private Codex app-server, passes only
302
391
  the authority's current access token through Codex's external-token login, answers its
303
392
  unauthorized callback through the authority, and revokes the binding on stop. The returned
304
393
  runtime exposes an interactive `codex --remote unix://…` launch descriptor and native
@@ -314,15 +403,37 @@ gone; an uncertain launch remains blocked. Callers must explicitly stop owned pr
314
403
  after their work finishes. Once reauthentication starts, it holds new admissions until the
315
404
  device operation settles. Only the private socket directory is removed on close. Codex's
316
405
  credential store remains ephemeral and does not read or write `auth.json` in that home.
406
+ Codex binds the app-server socket in its own per-user directory,
407
+ `<realpath /tmp>/codex-daemon-<euid>`, and publishes the private socket path as a link to it.
408
+ The daemon checks that publication with `@modelprofile.com/mcp-crossharness`'s
409
+ `observeCodexUnixSocket` and connects only when the link is exactly Codex's own, in a
410
+ directory other users cannot rewrite, and the socket behind it is the user's own in a 0700
411
+ directory of the user's; anything else fails the start with that package's error. Codex keeps a
412
+ zero-byte startup lock in that directory for each socket path it has served; the file is
413
+ Codex's own and authswitch does not remove it.
414
+ Managed Codex runs Codex 0.156.0 or newer -- the floor `@modelprofile.com/mcp-crossharness`'s
415
+ `requireCodexVersion` checks, the first release that publishes the socket alias -- and checks the
416
+ contract at every start instead of pinning one release. Before anything is recorded or started, the
417
+ `codex` it will run must report a readable version at or above the floor, and its own protocol
418
+ description (`codex app-server generate-json-schema`) must still offer every surface managed Codex uses:
419
+ the requests `account/login/start`, `thread/start`, `thread/resume` and `turn/start`; the external
420
+ ChatGPT token login with the fields it sends; the `account/chatgptAuthTokens/refresh` callback with the
421
+ `unauthorized` reason and the reply it gives; and the thread and turn parameters it sends. After
422
+ connecting, the app-server must report exactly the version that was checked, because Codex can replace
423
+ itself between the check and the start. Each failure is the `codex_unsupported` refusal naming the
424
+ version or the missing surface, so a Codex update that drops something managed Codex relies on stops new
425
+ managed sessions by name instead of failing one later. A prerelease above the floor runs; a prerelease of
426
+ 0.156.0 itself is below it.
317
427
  Managed-Codex process ownership observation is qualified on Linux and reads exact argument
318
428
  boundaries from `/proc`; on other hosts doctor reports the process observation as unknown,
319
429
  and authority actions that require survivor proof refuse instead of guessing.
320
- Codex 0.155.1 does not persist an unused thread created by `thread/start`; resume applies
430
+ Codex (verified through 0.157) does not persist an unused thread created by `thread/start`; resume applies
321
431
  after Codex has committed a rollout. The existing CLI has not yet been moved to this path.
322
432
 
323
433
  This SDK is additive at this stage. The commands documented below still use the existing
324
- credential stores. The authority daemon does not import those stores automatically,
325
- replace their active native sessions, or take over their refresh grants. An active
434
+ credential stores, except that the legacy fence below refuses them a login the authority
435
+ holds. The authority daemon does not import those stores automatically, replace their
436
+ active native sessions, or take over their refresh grants. An active
326
437
  Codex/OpenCode native grant remains pending until its owner is idle and an explicit
327
438
  handoff verifies the latest native source. Claude Code's own native credential files
328
439
  remain vendor-owned; a container setup-token grant is a separate grant under the same
@@ -353,6 +464,62 @@ restore system could have written.
353
464
  The store is deliberately not backed up: its refresh grants are sealed to this host's TPM,
354
465
  so a lost store is recovered by one device re-login per account.
355
466
 
467
+ ### The legacy fence
468
+
469
+ Once the authority has taken a saved copy over, the legacy `save` and `switch` must not write it again: an
470
+ adopted copy's refresh token was rotated away by the import, and activating it would hand a tool a dead
471
+ login. `runAuthSwitchMutation` -- the one path the command line, the guide, `watch` and the hosted
472
+ `AuthSwitchService` all take -- asks first, and decides in three branches.
473
+
474
+ - **The authority answers**: it is asked for this record's ledger row through `import status` on the
475
+ management socket, keyed by the same source id the importer uses (a hash of the saved copy's path). A
476
+ `verified` or `complete` row refuses with `authority_holds_login`; any other row, or none, lets the command
477
+ run. An authority that answers decides, whether or not its service unit is installed.
478
+ - **Nothing answers within four seconds -- no socket, a silent one, or no `XDG_RUNTIME_DIR` -- and the
479
+ authority's service unit is installed**: the command refuses with `authority_unavailable` and the
480
+ instruction to run `authswitch authority service start`. Unknown is never treated as idle.
481
+ - **Nothing answers and no unit is installed**: the legacy commands behave exactly as before. This is every
482
+ host before the cutover.
483
+
484
+ What tells the second branch from the third is durable, never the socket: the runtime directory is tmpfs,
485
+ so after a reboot the socket is absent until the user service starts -- exactly when a fence keyed on it
486
+ would open. The evidence is the unit `authswitch authority service install` writes,
487
+ `$XDG_DATA_HOME/systemd/user/authswitch-authority.service` (default
488
+ `~/.local/share/systemd/user/authswitch-authority.service`), read through smartdaemon's
489
+ `SystemdUnitFile.inspect()`. The installer and the fence derive that directory from one rule,
490
+ `$XDG_DATA_HOME` or else the home directory; the installer takes both from its own process and its account's
491
+ home, the fence from the locations its caller states. The two agree unless `$HOME` is overridden while
492
+ `XDG_DATA_HOME` is unset, and then every store the fence guards lies under that overridden home too. The fence
493
+ writes nothing and keeps no record of its own.
494
+
495
+ The check works without a login session. Reading the unit needs only its directory, so a cron job or a plain
496
+ ssh command with no `XDG_RUNTIME_DIR` is decided by the unit exactly like a desktop session: no unit, the
497
+ legacy commands run; the unit installed, they refuse with `authority_unavailable`. The stated
498
+ `XDG_RUNTIME_DIR` names only the socket to ask; an absent one is never replaced by this process's own. The
499
+ answer depends on the unit directory derived from the stated `XDG_DATA_HOME` or home, and on nothing else:
500
+ a malformed `XDG_RUNTIME_DIR` or `XDG_CONFIG_HOME` never refuses the command, and neither does a malformed
501
+ process `XDG_DATA_HOME` when the caller states other locations. A unit that cannot be inspected -- a file the
502
+ installer would not have written, a directory another user can write -- refuses rather than being read as
503
+ absent, and so does a stated `XDG_DATA_HOME` that names no usable unit directory, because the reader can
504
+ then tell an installed authority from none no better. On a platform without systemd no unit can be
505
+ installed.
506
+
507
+ The unit says an authority exists, not which stores it imports from, so while an installed authority is
508
+ stopped the legacy save and switch refuse for every saved copy of every store, until it runs again. A
509
+ record the importer could never have read -- an account id that names no saved-copy file -- is never asked
510
+ about.
511
+
512
+ `drop` / `rm` and the `remove` mutation are deliberately not fenced: they delete the user's own saved copy
513
+ and write no credential. A projected source is never fenced either -- its native store stays with its own
514
+ tool. The command line takes this host's locations only when it composes its own harnesses, and with
515
+ injected harnesses is fenced only by locations its caller states; `AuthSwitchOperations`,
516
+ `runAuthSwitchMutation` and an `AuthSwitchService` given its harnesses take the locations as a required
517
+ argument (`authSwitchHostLegacyFence()` names this host's, `'none'` states that a
518
+ caller owns no authority), so no library path reads a host it was not given. `AuthSwitchOperations` and the
519
+ hosted service ask the fence before their AGL coordinator as well, so a refused mutation never has its host
520
+ stop the runtimes it would restart. The hosted service reports a fence refusal as the operation's problem, in
521
+ the instruction's own words.
522
+
356
523
  ## Usage
357
524
 
358
525
  Run `authswitch`, `authswitch -i`, or `authswitch --interactive` for an interactive guide. Use the arrow keys and
@@ -515,9 +682,12 @@ login. `activateCredential(providerId, credential)` preserves and verifies the
515
682
  outgoing native login before activation; the caller retains ownership of the
516
683
  incoming credential's durable storage. Both are backend APIs, never wire payloads.
517
684
 
518
- `AuthSwitchOperations` is shared by the command, guide and TUI. Its optional
519
- coordinator receives only the harness, operation, account ID, consent to wait,
520
- and a credential-location fingerprint. AGL owns private-controller discovery;
685
+ `AuthSwitchOperations` is shared by the command, guide and TUI, and is built
686
+ with a coordinator (or `undefined`) and the legacy fence locations described in
687
+ "The legacy fence". A host that injects its own harnesses into `AuthSwitchService`
688
+ states that fence as the third argument. The coordinator receives only the
689
+ harness, operation, account ID, consent to wait, and a credential-location
690
+ fingerprint. AGL owns private-controller discovery;
521
691
  authswitch uses `agl authswitch --request <json>` without an AGL package dependency.
522
692
  A failed or ambiguous transport never falls through to a second local mutation.
523
693
  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.2.0',
6
+ version: '9.1.0',
7
7
  description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
@@ -15,11 +15,25 @@ export type TAuthSwitchRefusalCode =
15
15
  | 'account_changed'
16
16
  | 'account_busy'
17
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'
18
24
  | 'native_owner_holds_login'
19
25
  | 'claude_home_unregistered'
20
26
  | 'claude_handoff_pending'
21
27
  | 'claude_receipt_missing'
22
- | 'import_refusal';
28
+ | 'import_refusal'
29
+ /** The Codex on this host is older than managed Codex runs, or no longer offers a surface it uses. */
30
+ | 'codex_unsupported'
31
+ /** A value the owner typed breaks a rule the instruction states, such as an account label. */
32
+ | 'invalid_input'
33
+ /** The account's login has ended and only a new device sign-in brings it back. */
34
+ | 'login_needs_reauth'
35
+ /** The login is intact, but the provider could not renew its access yet; the same request can succeed later. */
36
+ | 'access_not_fresh';
23
37
 
24
38
  /** The marker the importer has published since 8.1.0; it keeps its own value on the wire. */
25
39
  export const authSwitchImportRefusalMarker = 'authswitch_import_refusal';
@@ -42,8 +56,10 @@ export interface IAuthSwitchRefusal {
42
56
 
43
57
  const refusalCodes: ReadonlySet<string> = new Set<TAuthSwitchRefusalCode>([
44
58
  'authority_closing', 'not_found', 'account_changed', 'account_busy',
45
- 'login_unavailable', 'native_owner_holds_login', 'claude_home_unregistered',
59
+ 'login_unavailable', 'binding_unauthorized', 'native_owner_holds_login', 'claude_home_unregistered',
46
60
  'claude_handoff_pending', 'claude_receipt_missing', 'import_refusal',
61
+ 'authority_holds_login', 'authority_unavailable', 'codex_unsupported', 'invalid_input',
62
+ 'login_needs_reauth', 'access_not_fresh',
47
63
  ]);
48
64
 
49
65
  const isRefusalCode = (value: unknown): value is TAuthSwitchRefusalCode =>
@@ -95,6 +111,18 @@ export const asAuthSwitchRefusal = (error: unknown): IAuthSwitchRefusal | null =
95
111
  return { code, instruction };
96
112
  };
97
113
 
114
+ /** The longest account label, in UTF-16 code units. */
115
+ export const authSwitchAccountLabelMaxLength = 128;
116
+
117
+ /**
118
+ * The account label rule, the one the authority applies to a rename: one to `authSwitchAccountLabelMaxLength`
119
+ * characters, no space at either end, no control character. A consumer validates with this rather than
120
+ * restating it; the authority answers a label that breaks it with the `invalid_input` refusal.
121
+ */
122
+ export const isAuthSwitchAccountLabel = (value: unknown): value is string => typeof value === 'string'
123
+ && value.trim() === value && value.length > 0 && value.length <= authSwitchAccountLabelMaxLength
124
+ && !/[\u0000-\u001f\u007f]/.test(value);
125
+
98
126
  /** Credential-free account management contract. Safe to import in browser code. */
99
127
  export type TAuthSwitchLoginPurpose = 'openai_managed' | 'claude_host_native' | 'claude_container_setup' | 'opencode_native';
100
128
  export type TAuthSwitchLoginHealth = 'ready' | 'refreshing' | 'retry_wait' | 'needs_reauth' | 'unverified' | 'pending_handoff' | 'handoff_quarantined' | 'removed';
@@ -110,6 +138,16 @@ export interface IAuthSwitchAccount {
110
138
  statusObservedAt: string;
111
139
  }
112
140
 
141
+ /**
142
+ * Which tool refreshes a natively owned login, named rather than inferred.
143
+ *
144
+ * `owner` says that a native tool holds the login; this says which one, so a consumer presents "Claude Code"
145
+ * or "Codex" without deriving it from `purpose` -- a derivation that would be wrong the moment two purposes
146
+ * share a tool, and that belongs to the side that decides ownership in the first place. It is `null` exactly
147
+ * when no native tool refreshes the login: the authority does, or nothing does.
148
+ */
149
+ export type TAuthSwitchLoginOwnerTool = 'claude_code' | 'codex' | 'opencode';
150
+
113
151
  /** One independently owned login. Native ownership does not imply observed health. */
114
152
  export interface IAuthSwitchLogin {
115
153
  id: string;
@@ -117,6 +155,8 @@ export interface IAuthSwitchLogin {
117
155
  providerId: string;
118
156
  purpose: TAuthSwitchLoginPurpose;
119
157
  owner: 'daemon' | 'claude_native' | 'legacy_native' | 'none';
158
+ /** The native tool that refreshes this login, or null when the authority or nobody does. */
159
+ ownerTool: TAuthSwitchLoginOwnerTool | null;
120
160
  health: TAuthSwitchLoginHealth;
121
161
  problem: 'none' | 'provider_unavailable' | 'exchange_uncertain' | 'provider_rejected' | 'native_owner';
122
162
  grantGeneration: number;
@@ -130,12 +170,35 @@ export interface IAuthSwitchLogin {
130
170
  export interface IAuthSwitchBinding {
131
171
  id: string;
132
172
  accountId: string;
173
+ /**
174
+ * The runtime an OpenAI (ChatGPT) login backs. `flex`, `opencode` and `claude` bindings are held and
175
+ * released by their caller over the runtime socket; `codex` is the daemon's own managed Codex. A Claude
176
+ * account never binds: it reaches Claude Code through the native switch.
177
+ */
133
178
  runtime: 'flex' | 'codex' | 'opencode' | 'claude';
134
179
  scopeId: string;
135
180
  incarnationId: string;
136
181
  revision: number;
137
182
  }
138
183
 
184
+ /**
185
+ * Which account a vendor tool's own home on this host runs on. Credential-free: the home is named by the
186
+ * hash the authority registered it under, never by its path.
187
+ *
188
+ * Only homes the authority itself switches are recorded: Claude Code homes adopted by a verified import.
189
+ * A Codex or OpenCode store that a native tool still refreshes appears as its login instead, with
190
+ * `owner: 'legacy_native'` and its `ownerTool`, because the authority does not decide what that store holds.
191
+ */
192
+ export interface IAuthSwitchNativeAssignment {
193
+ id: string;
194
+ tool: TAuthSwitchLoginOwnerTool;
195
+ accountId: string;
196
+ loginId: string;
197
+ /** `switching` while a handoff to another account is in flight; `quarantined` until it is resolved. */
198
+ state: 'ready' | 'switching' | 'quarantined';
199
+ revision: number;
200
+ }
201
+
139
202
  export interface IAuthSwitchSnapshot {
140
203
  schemaVersion: 2;
141
204
  epoch: string;
@@ -144,9 +207,11 @@ export interface IAuthSwitchSnapshot {
144
207
  accounts: IAuthSwitchAccount[];
145
208
  logins: IAuthSwitchLogin[];
146
209
  bindings: IAuthSwitchBinding[];
210
+ nativeAssignments: IAuthSwitchNativeAssignment[];
147
211
  nextAccountCursor: string | null;
148
212
  nextLoginCursor: string | null;
149
213
  nextBindingCursor: string | null;
214
+ nextNativeAssignmentCursor: string | null;
150
215
  }
151
216
 
152
217
  /** Persisted account evidence. No provider request is made while collecting diagnostics. */
@@ -372,7 +437,13 @@ export interface IReq_AuthSwitchClaudeNativeHandoffs extends ITypedRequest {
372
437
 
373
438
  export interface IReq_AuthSwitchSnapshot extends ITypedRequest {
374
439
  method: 'authswitch.authority.snapshot';
375
- request: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; limit?: number };
440
+ request: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; nativeAssignmentAfter?: string;
441
+ limit?: number;
442
+ /**
443
+ * Also publish removed accounts (`removed: true`) and their removed logins (`health: 'removed'`), so a
444
+ * consumer can show them apart. Absent or false keeps the snapshot to what is live.
445
+ */
446
+ includeRemoved?: boolean };
376
447
  response: { snapshot: IAuthSwitchSnapshot };
377
448
  }
378
449
 
@@ -422,9 +493,14 @@ export interface IReq_AuthSwitchListOperations extends ITypedRequest {
422
493
  response: { operations: IAuthSwitchOperation[]; nextCursor: string | null };
423
494
  }
424
495
 
496
+ /**
497
+ * One device sign-in. `afterRevision` and `waitMs` come together or not at all: with them the read is a long
498
+ * poll that answers once the operation's revision passes `afterRevision`, at once for a finished sign-in, or
499
+ * with the unchanged operation after `waitMs` (at most 30000).
500
+ */
425
501
  export interface IReq_AuthSwitchGetOperation extends ITypedRequest {
426
502
  method: 'authswitch.authority.operation';
427
- request: { operationId: string };
503
+ request: { operationId: string; afterRevision?: number; waitMs?: number };
428
504
  response: { operation: IAuthSwitchOperation };
429
505
  }
430
506
 
@@ -459,6 +535,16 @@ export interface IReq_AuthSwitchRenameAccount extends ITypedRequest {
459
535
  response: { account: IAuthSwitchAccount };
460
536
  }
461
537
 
538
+ /**
539
+ * One binding by the id `bind` returned, credential-free, or `null` when this authority holds no binding by
540
+ * that id. A backend checks that its binding still stands without reading the whole snapshot.
541
+ */
542
+ export interface IReq_AuthSwitchGetBinding extends ITypedRequest {
543
+ method: 'authswitch.authority.binding';
544
+ request: { bindingId: string };
545
+ response: { binding: IAuthSwitchBinding | null };
546
+ }
547
+
462
548
  export interface IReq_AuthSwitchRemoveAccount extends ITypedRequest {
463
549
  method: 'authswitch.authority.remove';
464
550
  request: { accountId: string; expectedRevision: number };
@@ -173,6 +173,12 @@ export const isAuthSwitchImportRefusal = (error: unknown): boolean =>
173
173
  export interface IAuthSwitchImportStatusEntry {
174
174
  sourceId: string;
175
175
  sourceKind: TAuthSwitchImportSourceKind;
176
+ /**
177
+ * The source location as a hash, exactly as it was submitted or inventoried. A backend that submits an
178
+ * external source finds its own record here by `sourceKind` and this hash, including after a submit whose
179
+ * outcome it never learned.
180
+ */
181
+ sourcePathHash: string;
176
182
  status: TAuthSwitchImportLedgerStatus;
177
183
  accountId: string | null;
178
184
  loginId: string | null;
@@ -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: {