@modelprofile.com/authswitch 9.0.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 (36) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +67 -1
  3. package/dist_ts/authority-contract.js +13 -2
  4. package/dist_ts/authority-import-contract.d.ts +6 -0
  5. package/dist_ts/classes.authoritybroker.d.ts +21 -15
  6. package/dist_ts/classes.authoritybroker.js +94 -31
  7. package/dist_ts/classes.authorityclient.d.ts +22 -2
  8. package/dist_ts/classes.authorityclient.js +79 -13
  9. package/dist_ts/classes.authoritydaemon.d.ts +7 -0
  10. package/dist_ts/classes.authoritydaemon.js +45 -31
  11. package/dist_ts/classes.authoritydatabase.d.ts +21 -3
  12. package/dist_ts/classes.authoritydatabase.js +88 -11
  13. package/dist_ts/classes.authorityimport.js +2 -2
  14. package/dist_ts/classes.authoritymodels.js +5 -3
  15. package/dist_ts/classes.codexmanaged.d.ts +0 -9
  16. package/dist_ts/classes.codexmanaged.js +8 -28
  17. package/dist_ts/codexcontract.d.ts +30 -0
  18. package/dist_ts/codexcontract.js +174 -0
  19. package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
  20. package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
  21. package/dist_ts/ts_migration/index.js +3 -1
  22. package/package.json +8 -8
  23. package/readme.md +75 -17
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/authority-contract.ts +71 -4
  26. package/ts/authority-import-contract.ts +6 -0
  27. package/ts/classes.authoritybroker.ts +94 -32
  28. package/ts/classes.authorityclient.ts +86 -15
  29. package/ts/classes.authoritydaemon.ts +44 -23
  30. package/ts/classes.authoritydatabase.ts +88 -11
  31. package/ts/classes.authorityimport.ts +1 -1
  32. package/ts/classes.authoritymodels.ts +4 -1
  33. package/ts/classes.codexmanaged.ts +6 -26
  34. package/ts/codexcontract.ts +200 -0
  35. package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
  36. package/ts/ts_migration/index.ts +2 -0
package/readme.md CHANGED
@@ -37,6 +37,18 @@ that tool -- `ownerTool` is `claude_code`, `codex` or `opencode`, and `null` exa
37
37
  native tool holds it, because the authority refreshes it or nothing does. The authority
38
38
  names it rather than leaving a consumer to derive it from the purpose, which would be wrong
39
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`.
40
52
 
41
53
  ```ts
42
54
  import { randomUUID } from 'node:crypto';
@@ -50,6 +62,11 @@ const operationId = randomUUID();
50
62
  const operation = await client.beginAddOpenAi(operationId);
51
63
  // Keep operation.id to discover its final result after navigation or reconnect.
52
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
+ }
53
70
  ```
54
71
 
55
72
  Browser code can import the credential-free DTOs from
@@ -64,7 +81,11 @@ never create one. A backend binds an account to a runtime incarnation by exact a
64
81
  purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
65
82
  exact login; an account's presentation default never selects a grant for either action. Completed and
66
83
  interrupted operations remain discoverable through `getOperation()` and paged
67
- `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
68
89
  the receipt resolves a lost response or an event that arrives first. Add and reauthentication
69
90
  callers supply one UUID for the logical start and reuse it after a lost response. Replaying that
70
91
  UUID returns the same matching receipt without starting another provider login; using it for a
@@ -75,16 +96,29 @@ resolves that binding to a current access token; its directory can be mounted in
75
96
  container without exposing the management socket. A provider that rejects a specific
76
97
  access-token generation can request a newer one through `rejectedGrantGeneration`; the
77
98
  authority coalesces concurrent rejection callbacks and never replays an uncertain refresh.
78
- After an external Flex or OpenCode runtime has fenced new work and drained its pending access
79
- resolutions and provider requests, it can release its exact binding capability through the
80
- 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
81
103
  or delete a successor even if the caller reused an incarnation label. Release waits for admitted
82
104
  server-side resolution handlers, but handler completion does not prove response delivery and
83
105
  cannot retract an access token already received or in use; the runtime owner therefore owns that
84
106
  drain. Generic release cannot revoke a managed Codex binding, whose daemon-owned stop still closes
85
107
  the runtime and clears its durable run before internal revocation. Binding release never transfers
86
- or changes ownership of the account grant. External-binding recovery after an owner crash remains
87
- 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.
88
122
 
89
123
  Every route on either socket answers in one of three ways: a result, a **refusal**, or a fault. A refusal
90
124
  is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
@@ -99,7 +133,8 @@ that marker: it answers `{ code, instruction }` for a refusal and `null` for a f
99
133
  closed `TAuthSwitchRefusalCode` set, which is what a client branches on without reading the text:
100
134
  `authority_closing`, `not_found`, `account_changed`, `account_busy`, `login_unavailable`,
101
135
  `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
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
103
138
  the socket, `authority_holds_login` and `authority_unavailable` (see "The legacy fence" below). The
104
139
  instruction is authored for the owner and contains no path, credential or digest; only an importer refusal
105
140
  may name the process the owner has to stop. The set is producer-owned: a consumer branches on the codes it
@@ -112,10 +147,12 @@ credential was being resolved. Those are one answer on purpose: an absent bindin
112
147
  compared against the same zero hash, so nothing distinguishes them, and the repair is the same either way --
113
148
  bind again. A binding whose account has since been reauthorized reads `login_unavailable` instead, because
114
149
  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.`,
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.`,
119
156
  which a socket caller reads as `Internal server error`, because naming it is a decision about which layer
120
157
  authors owner instructions rather than a wording fix.
121
158
 
@@ -128,6 +165,15 @@ and the ledger row it wrote says where that source stands -- while an unmarked f
128
165
  unknown, and that source must then be read with `authswitch.authority.import.status` rather than
129
166
  submitted again.
130
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
+
131
177
  `AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus })` reports `current`
132
178
  after a fresh snapshot or verified heartbeat, `unavailable` on disconnect or resync, and
133
179
  `closed` on abort. Keep cached account actions disabled while status is not `current`.
@@ -293,7 +339,9 @@ that refusal nor "already imported" ever clears: no route forgets a row or re-po
293
339
 
294
340
  `import status` reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per
295
341
  source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same
296
- 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,
297
345
  and the answer comes from the same durable record the daemon decides on: a source whose sign-in was in
298
346
  flight when the run died is reported as needing a device sign-in, not as resumable, because a rotating
299
347
  refresh token is never sent a second time. Sources that were never submitted have no ledger row and appear
@@ -339,7 +387,7 @@ generated prose and credentials are absent from persistence and management respo
339
387
  removal and targeted reauthentication refuse while that account has an active preuse request.
340
388
 
341
389
  A trusted in-process daemon host can call `AuthSwitchAuthorityDaemon.startManagedCodex()`
342
- for a bound account and workspace. It owns a private Codex 0.157.0 app-server, passes only
390
+ for a bound account and workspace. It owns a private Codex app-server, passes only
343
391
  the authority's current access token through Codex's external-token login, answers its
344
392
  unauthorized callback through the authority, and revokes the binding on stop. The returned
345
393
  runtime exposes an interactive `codex --remote unix://…` launch descriptor and native
@@ -363,13 +411,23 @@ directory other users cannot rewrite, and the socket behind it is the user's own
363
411
  directory of the user's; anything else fails the start with that package's error. Codex keeps a
364
412
  zero-byte startup lock in that directory for each socket path it has served; the file is
365
413
  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.
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.
369
427
  Managed-Codex process ownership observation is qualified on Linux and reads exact argument
370
428
  boundaries from `/proc`; on other hosts doctor reports the process observation as unknown,
371
429
  and authority actions that require survivor proof refuse instead of guessing.
372
- Codex 0.157.0 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
373
431
  after Codex has committed a rollout. The existing CLI has not yet been moved to this path.
374
432
 
375
433
  This SDK is additive at this stage. The commands documented below still use the existing
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '9.0.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
  }
@@ -25,7 +25,15 @@ export type TAuthSwitchRefusalCode =
25
25
  | 'claude_home_unregistered'
26
26
  | 'claude_handoff_pending'
27
27
  | 'claude_receipt_missing'
28
- | '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';
29
37
 
30
38
  /** The marker the importer has published since 8.1.0; it keeps its own value on the wire. */
31
39
  export const authSwitchImportRefusalMarker = 'authswitch_import_refusal';
@@ -50,7 +58,8 @@ const refusalCodes: ReadonlySet<string> = new Set<TAuthSwitchRefusalCode>([
50
58
  'authority_closing', 'not_found', 'account_changed', 'account_busy',
51
59
  'login_unavailable', 'binding_unauthorized', 'native_owner_holds_login', 'claude_home_unregistered',
52
60
  'claude_handoff_pending', 'claude_receipt_missing', 'import_refusal',
53
- 'authority_holds_login', 'authority_unavailable',
61
+ 'authority_holds_login', 'authority_unavailable', 'codex_unsupported', 'invalid_input',
62
+ 'login_needs_reauth', 'access_not_fresh',
54
63
  ]);
55
64
 
56
65
  const isRefusalCode = (value: unknown): value is TAuthSwitchRefusalCode =>
@@ -102,6 +111,18 @@ export const asAuthSwitchRefusal = (error: unknown): IAuthSwitchRefusal | null =
102
111
  return { code, instruction };
103
112
  };
104
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
+
105
126
  /** Credential-free account management contract. Safe to import in browser code. */
106
127
  export type TAuthSwitchLoginPurpose = 'openai_managed' | 'claude_host_native' | 'claude_container_setup' | 'opencode_native';
107
128
  export type TAuthSwitchLoginHealth = 'ready' | 'refreshing' | 'retry_wait' | 'needs_reauth' | 'unverified' | 'pending_handoff' | 'handoff_quarantined' | 'removed';
@@ -149,12 +170,35 @@ export interface IAuthSwitchLogin {
149
170
  export interface IAuthSwitchBinding {
150
171
  id: string;
151
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
+ */
152
178
  runtime: 'flex' | 'codex' | 'opencode' | 'claude';
153
179
  scopeId: string;
154
180
  incarnationId: string;
155
181
  revision: number;
156
182
  }
157
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
+
158
202
  export interface IAuthSwitchSnapshot {
159
203
  schemaVersion: 2;
160
204
  epoch: string;
@@ -163,9 +207,11 @@ export interface IAuthSwitchSnapshot {
163
207
  accounts: IAuthSwitchAccount[];
164
208
  logins: IAuthSwitchLogin[];
165
209
  bindings: IAuthSwitchBinding[];
210
+ nativeAssignments: IAuthSwitchNativeAssignment[];
166
211
  nextAccountCursor: string | null;
167
212
  nextLoginCursor: string | null;
168
213
  nextBindingCursor: string | null;
214
+ nextNativeAssignmentCursor: string | null;
169
215
  }
170
216
 
171
217
  /** Persisted account evidence. No provider request is made while collecting diagnostics. */
@@ -391,7 +437,13 @@ export interface IReq_AuthSwitchClaudeNativeHandoffs extends ITypedRequest {
391
437
 
392
438
  export interface IReq_AuthSwitchSnapshot extends ITypedRequest {
393
439
  method: 'authswitch.authority.snapshot';
394
- 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 };
395
447
  response: { snapshot: IAuthSwitchSnapshot };
396
448
  }
397
449
 
@@ -441,9 +493,14 @@ export interface IReq_AuthSwitchListOperations extends ITypedRequest {
441
493
  response: { operations: IAuthSwitchOperation[]; nextCursor: string | null };
442
494
  }
443
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
+ */
444
501
  export interface IReq_AuthSwitchGetOperation extends ITypedRequest {
445
502
  method: 'authswitch.authority.operation';
446
- request: { operationId: string };
503
+ request: { operationId: string; afterRevision?: number; waitMs?: number };
447
504
  response: { operation: IAuthSwitchOperation };
448
505
  }
449
506
 
@@ -478,6 +535,16 @@ export interface IReq_AuthSwitchRenameAccount extends ITypedRequest {
478
535
  response: { account: IAuthSwitchAccount };
479
536
  }
480
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
+
481
548
  export interface IReq_AuthSwitchRemoveAccount extends ITypedRequest {
482
549
  method: 'authswitch.authority.remove';
483
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;
@@ -1,9 +1,10 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
3
- IAuthSwitchOperation, IAuthSwitchSnapshot, TAuthSwitchLoginOwnerTool } from './authority-contract.js';
4
- import { AuthSwitchRefusal } from './authority-contract.js';
3
+ IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
4
+ TAuthSwitchLoginOwnerTool } from './authority-contract.js';
5
+ import { AuthSwitchRefusal, isAuthSwitchAccountLabel } from './authority-contract.js';
5
6
  import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
6
- import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
7
+ import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityClaudeHome, IStoredAuthorityGrant,
7
8
  IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
8
9
  import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
9
10
  import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
@@ -41,8 +42,6 @@ const sameUsageAuthority = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsa
41
42
  const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
42
43
  const deviceOperationTerminal = (operation: IStoredAuthorityDeviceOperation): boolean =>
43
44
  !['starting', 'pending', 'committing'].includes(operation.state);
44
- const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
45
- && value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
46
45
  const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
47
46
  && value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
48
47
  const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
@@ -70,6 +69,10 @@ const bindingMoved = 'This runtime binding changed while its credential was bein
70
69
  + 'account again.';
71
70
  const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
72
71
  + 'Reauthenticate the account, then bind it again.';
72
+ const loginNeedsReauth = 'This account\'s sign-in has ended. Reauthenticate the account with a new device '
73
+ + 'sign-in; a runtime bound to it binds again afterwards.';
74
+ const accessNotFresh = 'The provider could not renew this account\'s access yet. Try the request again '
75
+ + 'shortly; the authority keeps retrying the renewal.';
73
76
  const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
74
77
  id: account.id, providerId: account.providerId, label: account.label, email: account.email,
75
78
  plan: account.plan, removed: account.removed, revision: account.revision,
@@ -133,6 +136,12 @@ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding =>
133
136
  scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
134
137
  });
135
138
 
139
+ const publicClaudeAssignment = (home: IStoredAuthorityClaudeHome): IAuthSwitchNativeAssignment => ({
140
+ id: home.id, tool: 'claude_code', accountId: home.activeAccountId, loginId: home.activeGrantId,
141
+ state: home.status === 'quarantined' ? 'quarantined' : home.pendingOperationId !== null ? 'switching' : 'ready',
142
+ revision: home.revision,
143
+ });
144
+
136
145
  interface IManagedOperation {
137
146
  id: string;
138
147
  handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
@@ -154,6 +163,8 @@ export class AuthSwitchAuthorityBroker {
154
163
  private readonly operations = new Map<string, IManagedOperation>();
155
164
  private readonly refreshes = new Map<string, Promise<void>>();
156
165
  private readonly listeners = new Set<() => void>();
166
+ /** Operation long polls; woken by their operation's own changes, and all of them on close. */
167
+ private readonly operationWaiters = new Set<() => void>();
157
168
  private claudeRefresh?: (grantId: string) => Promise<void>;
158
169
  private maintenance?: Promise<void>;
159
170
  private timer?: NodeJS.Timeout;
@@ -267,19 +278,23 @@ export class AuthSwitchAuthorityBroker {
267
278
  for (const listener of this.listeners) listener();
268
279
  }
269
280
 
270
- public async snapshot(options: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
281
+ public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}): Promise<IAuthSwitchSnapshot> {
271
282
  if ((options.accountAfter !== undefined && !isId(options.accountAfter))
272
283
  || (options.loginAfter !== undefined && !isId(options.loginAfter))
273
- || (options.bindingAfter !== undefined && !isId(options.bindingAfter))) throw new Error('Invalid account snapshot cursor.');
284
+ || (options.bindingAfter !== undefined && !isId(options.bindingAfter))
285
+ || (options.nativeAssignmentAfter !== undefined && !isId(options.nativeAssignmentAfter))) {
286
+ throw new Error('Invalid account snapshot cursor.');
287
+ }
274
288
  const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
275
- options.bindingAfter ?? null, options.limit ?? 128);
289
+ options.bindingAfter ?? null, options.nativeAssignmentAfter ?? null, options.limit ?? 128);
276
290
  return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
277
291
  generatedAt: new Date(this.now()).toISOString(),
278
- accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
279
- logins: page.grants.filter(grant => grant.state !== 'removed').map(publicLogin),
292
+ accounts: page.accounts.filter(account => options.includeRemoved || !account.removed).map(publicAccount),
293
+ logins: page.grants.filter(grant => options.includeRemoved || grant.state !== 'removed').map(publicLogin),
280
294
  bindings: page.bindings.map(publicBinding),
295
+ nativeAssignments: page.claudeHomes.map(publicClaudeAssignment),
281
296
  nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
282
- nextBindingCursor: page.nextBindingCursor };
297
+ nextBindingCursor: page.nextBindingCursor, nextNativeAssignmentCursor: page.nextClaudeHomeCursor };
283
298
  }
284
299
 
285
300
  public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
@@ -554,11 +569,46 @@ export class AuthSwitchAuthorityBroker {
554
569
  return publicOperation(pending);
555
570
  }
556
571
 
557
- public async getOperation(operationId: string): Promise<IAuthSwitchOperation> {
572
+ /**
573
+ * One device sign-in. With `wait`, the read is a long poll, the way `events` waits: it answers at once when
574
+ * the operation's revision is past `afterRevision` or the sign-in has finished, and otherwise when the
575
+ * operation next changes or `waitMs` runs out, whichever is first -- so a caller follows the prompt and
576
+ * the outcome without polling. A timed-out wait answers with the unchanged operation.
577
+ */
578
+ public async getOperation(operationId: string, wait?: { afterRevision: number; waitMs: number },
579
+ signal?: AbortSignal): Promise<IAuthSwitchOperation> {
558
580
  if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
559
- const operation = await this.database.readOperation(operationId);
560
- if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
561
- return publicOperation(operation);
581
+ if (wait !== undefined && (!validRevision(wait.afterRevision) || !Number.isSafeInteger(wait.waitMs)
582
+ || wait.waitMs < 0 || wait.waitMs > 30_000)) throw new Error('Invalid operation wait.');
583
+ const read = async (): Promise<IStoredAuthorityDeviceOperation> => {
584
+ const operation = await this.database.readOperation(operationId);
585
+ if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
586
+ return operation;
587
+ };
588
+ const answers = (operation: IStoredAuthorityDeviceOperation): boolean => wait === undefined
589
+ || operation.revision > wait.afterRevision || deviceOperationTerminal(operation);
590
+ const immediate = await read();
591
+ if (answers(immediate) || wait!.waitMs === 0 || this.closed || signal?.aborted) return publicOperation(immediate);
592
+ return new Promise((resolve, reject) => {
593
+ let finished = false;
594
+ const settle = (outcome: () => void) => {
595
+ if (finished) return;
596
+ finished = true;
597
+ clearTimeout(timer);
598
+ unobserve();
599
+ this.operationWaiters.delete(wake);
600
+ signal?.removeEventListener('abort', wake);
601
+ outcome();
602
+ };
603
+ const wake = () => settle(() => { void read().then(operation => resolve(publicOperation(operation)), reject); });
604
+ const timer = setTimeout(wake, wait!.waitMs);
605
+ const unobserve = this.database.observeDeviceOperations(changed => { if (changed === operationId) wake(); });
606
+ this.operationWaiters.add(wake);
607
+ signal?.addEventListener('abort', wake, { once: true });
608
+ // Close the read/register race without holding a database session for the wait.
609
+ void read().then(operation => { if (answers(operation)) wake(); },
610
+ error => settle(() => reject(error)));
611
+ });
562
612
  }
563
613
 
564
614
  public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
@@ -595,7 +645,11 @@ export class AuthSwitchAuthorityBroker {
595
645
  }
596
646
 
597
647
  public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
598
- if (!isId(accountId) || !validRevision(expectedRevision) || !safeLabel(label)) throw new Error('Invalid account change.');
648
+ if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
649
+ if (!isAuthSwitchAccountLabel(label)) {
650
+ throw new AuthSwitchRefusal('invalid_input', 'An account label has between one and one hundred and twenty-eight '
651
+ + 'characters, with no space at either end and no control character. Choose another label.');
652
+ }
599
653
  const updateId = plugins.crypto.randomUUID();
600
654
  const updated = await this.database.changeAccount(updateId, accountId, account => {
601
655
  if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
@@ -625,10 +679,11 @@ export class AuthSwitchAuthorityBroker {
625
679
  const capabilityHash = idHash(capability);
626
680
  const updateId = plugins.crypto.randomUUID();
627
681
  const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
682
+ // A ChatGPT login backs every runtime, Claude Code included; a Claude account reaches Claude Code
683
+ // through its native switch instead, so it never binds (its provider is not OpenAI).
628
684
  if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
629
685
  || grant.owner !== 'daemon'
630
- || grant.purpose !== input.purpose || account.providerId !== 'openai'
631
- || input.runtime === 'claude') {
686
+ || grant.purpose !== input.purpose || account.providerId !== 'openai') {
632
687
  throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
633
688
  }
634
689
  return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
@@ -639,6 +694,12 @@ export class AuthSwitchAuthorityBroker {
639
694
  return { binding: publicBinding(updated.binding), capability };
640
695
  }
641
696
 
697
+ public async getBinding(bindingId: string): Promise<IAuthSwitchBinding | null> {
698
+ if (!isId(bindingId)) throw new Error('Invalid runtime binding.');
699
+ const binding = await this.database.readBinding(bindingId);
700
+ return binding ? publicBinding(binding) : null;
701
+ }
702
+
642
703
  public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
643
704
  if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
644
705
  const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
@@ -710,13 +771,12 @@ export class AuthSwitchAuthorityBroker {
710
771
  }
711
772
 
712
773
  /**
713
- * The shared managed-access loop, whose refusals are deliberately still unmarked faults.
714
- *
715
- * Its two callers repair differently: a runtime holding a binding binds again, while the usage reader has
716
- * no binding at all and collapses everything into one usage problem. Naming a code here would either say
717
- * "binding" to a caller that has none, or invent a second meaning for one that does, so the decision waits
718
- * for the slice that gives the usage answer its own vocabulary. What a bound runtime can act on is decided
719
- * before this point, in the view `resolveAccess` supplies.
774
+ * The shared managed-access loop. What it decides about the login itself is a marked refusal both callers
775
+ * read the same way: `login_needs_reauth` when only a new sign-in brings the login back, `access_not_fresh`
776
+ * when the login is intact and the provider could not renew it yet. The usage reader still folds either
777
+ * into its own problem; a bound runtime shows the instruction. What only concerns a binding is decided
778
+ * before this point, in the view `resolveAccess` supplies, and a changed identity or a view that keeps
779
+ * moving stays an unmarked fault, because nobody decided it.
720
780
  */
721
781
  private async resolveManagedAccess(readView: () => Promise<{
722
782
  account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
@@ -725,20 +785,20 @@ export class AuthSwitchAuthorityBroker {
725
785
  const { account, grant } = await readView();
726
786
  if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
727
787
  || account.providerId !== 'openai' || grant.providerId !== 'openai') {
728
- throw new Error('Account needs reauthentication.');
788
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
729
789
  }
730
790
  if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
731
791
  throw new Error('Provider rejected a future account grant generation.');
732
792
  }
733
793
  if (grant.state === 'exchange_may_have_been_sent') {
734
794
  const inFlight = this.refreshes.get(account.id);
735
- if (!inFlight) throw new Error('Account refresh is unresolved; reauthentication is required.');
795
+ if (!inFlight) throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
736
796
  await inFlight;
737
797
  continue;
738
798
  }
739
799
  if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
740
800
  || !['ready', 'retry_wait'].includes(grant.state)) {
741
- throw new Error('Account needs reauthentication.');
801
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
742
802
  }
743
803
  const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
744
804
  if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
@@ -747,9 +807,9 @@ export class AuthSwitchAuthorityBroker {
747
807
  rejectedCurrent ? rejectedGrantGeneration : undefined);
748
808
  continue;
749
809
  }
750
- throw new Error('Account access credential is not fresh enough.');
810
+ throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
751
811
  }
752
- if (!grant.accessExpiresAt) throw new Error('Account access credential is not fresh enough.');
812
+ if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
753
813
  const credential = await this.unsealCredential(account, grant);
754
814
  const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
755
815
  if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
@@ -767,7 +827,7 @@ export class AuthSwitchAuthorityBroker {
767
827
  || latest.grant.state === 'exchange_may_have_been_sent') continue;
768
828
  if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
769
829
  || (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
770
- throw new Error('Account access credential is not fresh enough.');
830
+ throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
771
831
  }
772
832
  return { accessToken: credential.accessToken, accountId: account.workspaceId,
773
833
  isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
@@ -852,7 +912,8 @@ export class AuthSwitchAuthorityBroker {
852
912
  });
853
913
  this.publish();
854
914
  } catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
855
- throw new Error('Refresh result is uncertain. Reauthenticate this account; its old grant will not be replayed.');
915
+ // The grant is now `needs_reauth`: an uncertain rotation is never replayed, so only a sign-in repairs it.
916
+ throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
856
917
  }
857
918
  }
858
919
 
@@ -861,6 +922,7 @@ export class AuthSwitchAuthorityBroker {
861
922
  this.closed = true;
862
923
  if (this.timer) clearTimeout(this.timer);
863
924
  for (const wake of this.listeners) wake();
925
+ for (const wake of this.operationWaiters) wake();
864
926
  this.closing = (async () => {
865
927
  if (this.maintenance) await this.maintenance;
866
928
  await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));