@modelprofile.com/authswitch 6.5.0 → 8.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 (95) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +300 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-runtime-contract.d.ts +13 -0
  5. package/dist_ts/classes.accountlist.js +4 -3
  6. package/dist_ts/classes.authoritybroker.d.ts +25 -5
  7. package/dist_ts/classes.authoritybroker.js +388 -123
  8. package/dist_ts/classes.authoritycli.d.ts +24 -0
  9. package/dist_ts/classes.authoritycli.js +675 -0
  10. package/dist_ts/classes.authorityclient.d.ts +19 -7
  11. package/dist_ts/classes.authorityclient.js +51 -14
  12. package/dist_ts/classes.authoritydaemon.d.ts +28 -1
  13. package/dist_ts/classes.authoritydaemon.js +477 -36
  14. package/dist_ts/classes.authoritydatabase.d.ts +155 -11
  15. package/dist_ts/classes.authoritydatabase.js +1037 -69
  16. package/dist_ts/classes.authoritymodels.d.ts +229 -23
  17. package/dist_ts/classes.authoritymodels.js +867 -128
  18. package/dist_ts/classes.authoritypreuse.d.ts +47 -0
  19. package/dist_ts/classes.authoritypreuse.js +277 -0
  20. package/dist_ts/classes.authorityregistry.d.ts +9 -0
  21. package/dist_ts/classes.authorityregistry.js +32 -0
  22. package/dist_ts/classes.authoritysecrets.d.ts +1 -1
  23. package/dist_ts/classes.authoritysecrets.js +2 -2
  24. package/dist_ts/classes.authorityusage.d.ts +160 -0
  25. package/dist_ts/classes.authorityusage.js +429 -0
  26. package/dist_ts/classes.claudeauthority.d.ts +66 -0
  27. package/dist_ts/classes.claudeauthority.js +528 -0
  28. package/dist_ts/classes.claudecodeharness.js +3 -2
  29. package/dist_ts/classes.claudecodelocks.d.ts +13 -0
  30. package/dist_ts/classes.claudecodelocks.js +75 -3
  31. package/dist_ts/classes.claudenative.d.ts +130 -0
  32. package/dist_ts/classes.claudenative.js +708 -0
  33. package/dist_ts/classes.claudestatus.d.ts +9 -0
  34. package/dist_ts/classes.claudestatus.js +26 -9
  35. package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
  36. package/dist_ts/classes.claudetokenrefresh.js +74 -21
  37. package/dist_ts/classes.codexharness.js +4 -2
  38. package/dist_ts/classes.codexmanaged.d.ts +12 -1
  39. package/dist_ts/classes.codexmanaged.js +75 -14
  40. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  41. package/dist_ts/classes.codexpreuse.js +24 -6
  42. package/dist_ts/classes.fileharness.d.ts +2 -0
  43. package/dist_ts/classes.fileharness.js +6 -4
  44. package/dist_ts/classes.limits.js +12 -7
  45. package/dist_ts/classes.opencodeharness.js +2 -2
  46. package/dist_ts/claudehttp.d.ts +10 -0
  47. package/dist_ts/claudehttp.js +29 -3
  48. package/dist_ts/index.d.ts +1 -0
  49. package/dist_ts/index.js +7 -1
  50. package/dist_ts/interfaces.harness.d.ts +6 -0
  51. package/dist_ts/interfaces.list.d.ts +9 -3
  52. package/dist_ts/plugins.d.ts +6 -3
  53. package/dist_ts/plugins.js +8 -4
  54. package/dist_ts/ts_migration/0001_authority_meta.d.ts +13 -0
  55. package/dist_ts/ts_migration/0001_authority_meta.js +44 -0
  56. package/dist_ts/ts_migration/0002_remove_backup_records.d.ts +17 -0
  57. package/dist_ts/ts_migration/0002_remove_backup_records.js +48 -0
  58. package/dist_ts/ts_migration/index.d.ts +6 -0
  59. package/dist_ts/ts_migration/index.js +12 -0
  60. package/package.json +3 -3
  61. package/readme.md +177 -14
  62. package/ts/00_commitinfo_data.ts +1 -1
  63. package/ts/authority-contract.ts +262 -9
  64. package/ts/authority-runtime-contract.ts +14 -0
  65. package/ts/classes.accountlist.ts +3 -2
  66. package/ts/classes.authoritybroker.ts +385 -121
  67. package/ts/classes.authoritycli.ts +719 -0
  68. package/ts/classes.authorityclient.ts +75 -18
  69. package/ts/classes.authoritydaemon.ts +444 -36
  70. package/ts/classes.authoritydatabase.ts +1076 -75
  71. package/ts/classes.authoritymodels.ts +642 -144
  72. package/ts/classes.authoritypreuse.ts +296 -0
  73. package/ts/classes.authorityregistry.ts +47 -0
  74. package/ts/classes.authoritysecrets.ts +1 -1
  75. package/ts/classes.authorityusage.ts +527 -0
  76. package/ts/classes.claudeauthority.ts +537 -0
  77. package/ts/classes.claudecodeharness.ts +2 -1
  78. package/ts/classes.claudecodelocks.ts +70 -1
  79. package/ts/classes.claudenative.ts +785 -0
  80. package/ts/classes.claudestatus.ts +38 -9
  81. package/ts/classes.claudetokenrefresh.ts +82 -19
  82. package/ts/classes.codexharness.ts +3 -1
  83. package/ts/classes.codexmanaged.ts +76 -13
  84. package/ts/classes.codexpreuse.ts +33 -5
  85. package/ts/classes.fileharness.ts +7 -3
  86. package/ts/classes.limits.ts +11 -6
  87. package/ts/classes.opencodeharness.ts +1 -1
  88. package/ts/claudehttp.ts +31 -2
  89. package/ts/index.ts +6 -0
  90. package/ts/interfaces.harness.ts +6 -0
  91. package/ts/interfaces.list.ts +9 -3
  92. package/ts/plugins.ts +8 -3
  93. package/ts/ts_migration/0001_authority_meta.ts +45 -0
  94. package/ts/ts_migration/0002_remove_backup_records.ts +49 -0
  95. package/ts/ts_migration/index.ts +12 -0
@@ -1,32 +1,58 @@
1
1
  import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
2
  import * as plugins from './plugins.js';
3
3
  import type {
4
+ IAuthSwitchDoctorPage, IReq_AuthSwitchDoctor,
4
5
  IReq_AuthSwitchBeginAdd, IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
5
- IReq_AuthSwitchEvents, IReq_AuthSwitchGetOperation, IReq_AuthSwitchRemoveAccount,
6
+ IReq_AuthSwitchClaudeNativeHandoff, IReq_AuthSwitchClaudeNativeHandoffs, IReq_AuthSwitchSwitchClaudeNative,
7
+ IReq_AuthSwitchEvents, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations, IReq_AuthSwitchRemoveAccount,
6
8
  IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSnapshot,
9
+ IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
7
10
  } from './authority-contract.js';
8
- import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
9
- import { AuthSwitchAuthorityBroker, type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
11
+ import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
12
+ IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
13
+ import { authSwitchBindingCapabilityHash, AuthSwitchAuthorityBroker,
14
+ type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
10
15
  import { AuthSwitchAuthorityDatabase, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
16
+ import { ClaudeNativeAuthority, type IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
17
+ import { ClaudeAccountStatus } from './classes.claudestatus.js';
18
+ import { AuthSwitchAuthorityUsage, type IAuthSwitchAuthorityUsageOptions } from './classes.authorityusage.js';
19
+ import { AuthSwitchAuthorityPreuse, type IAuthSwitchAuthorityPreuseOptions } from './classes.authoritypreuse.js';
11
20
  import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
12
21
  maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
13
- import { AuthSwitchManagedCodex, findManagedCodexProcess, type IAuthSwitchManagedCodexOptions } from './classes.codexmanaged.js';
22
+ import { AuthSwitchManagedCodex, findManagedCodexProcess, observeManagedCodexProcesses,
23
+ type IAuthSwitchManagedCodexOptions } from './classes.codexmanaged.js';
14
24
 
15
25
  export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityDatabaseOptions {
16
26
  authoritySocketPath: string;
17
27
  runtimeSocketPath: string;
18
28
  broker?: IAuthSwitchAuthorityBrokerOptions;
29
+ usage?: Partial<Pick<IAuthSwitchAuthorityUsageOptions, 'now' | 'currentMaxAgeMs' | 'maxStaleAgeMs'
30
+ | 'minAttemptIntervalMs' | 'retryBaseMs' | 'retryMaxMs' | 'providerTimeoutMs' | 'minAccessValidityMs'>>;
31
+ preuse?: IAuthSwitchAuthorityPreuseOptions;
32
+ claudeStatus?: ClaudeAccountStatus;
33
+ /** Activated only after a verified backend source receipt has registered the native home. */
34
+ claudeNative?: IClaudeNativeAuthorityOptions;
19
35
  }
20
36
 
21
37
  const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
22
38
  value !== null && typeof value === 'object' && !Array.isArray(value)
23
39
  && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
24
40
  const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
41
+ const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
42
+ const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
43
+ && /^[A-Za-z0-9_-]{43}$/.test(value);
44
+
45
+ interface IPreuseAccountReservation {
46
+ operationId: string;
47
+ }
25
48
 
26
49
  /** TypedRequest handlers over a private one-request-per-connection Unix socket. */
27
50
  export class AuthSwitchAuthorityDaemon {
28
51
  public readonly database: AuthSwitchAuthorityDatabase;
29
52
  public readonly broker: AuthSwitchAuthorityBroker;
53
+ public readonly usage: AuthSwitchAuthorityUsage;
54
+ public readonly preuse: AuthSwitchAuthorityPreuse;
55
+ public readonly claudeNative?: ClaudeNativeAuthority;
30
56
  private readonly managementRouter = new plugins.typedrequest.TypedRouter();
31
57
  private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
32
58
  private managementServer?: plugins.net.Server;
@@ -36,25 +62,192 @@ export class AuthSwitchAuthorityDaemon {
36
62
  capability: string; homeId: string; runId: string }>>();
37
63
  private readonly managedCodexHomes = new Set<string>();
38
64
  private readonly managedCodexAccountIds = new Map<string, string>();
65
+ private readonly managedCodexStates = new Map<string, IAuthSwitchDoctorPage['managedCodex'][number]['daemon']['state']>();
39
66
  private readonly drainingAccounts = new Set<string>();
67
+ private readonly deviceDrainOwners = new Map<string, string>();
68
+ private readonly preuseAccounts = new Map<string, IPreuseAccountReservation>();
40
69
  private readonly managedCodexStops = new Map<string, Promise<void>>();
41
70
  private readonly managedCodexFailures: unknown[] = [];
71
+ private readonly runtimeBindingActivity = new Map<string, { active: number; waiters: Set<() => void> }>();
72
+ private readonly runtimeBindingReleaseFences = new Set<string>();
73
+ private readonly runtimeBindingReleases = new Map<string, Promise<IReq_AuthSwitchReleaseBinding['response']>>();
42
74
  private closing = false;
43
75
 
44
76
  constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
45
77
  this.database = new AuthSwitchAuthorityDatabase(options);
46
- this.broker = new AuthSwitchAuthorityBroker(this.database, options.broker);
78
+ const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
79
+ this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
80
+ this.usage = new AuthSwitchAuthorityUsage({
81
+ ...options.usage,
82
+ readContext: (accountId, loginId) => this.database.readUsageContext(accountId, loginId),
83
+ resolveAccess: (context, rejectedGrantGeneration, minValidityMs) =>
84
+ this.broker.resolveUsageAccess(context, rejectedGrantGeneration, minValidityMs),
85
+ resolveClaudeLogin: (context, rejectedGrantGeneration, minValidityMs) => {
86
+ if (!this.claudeNative) throw new Error('Claude status authority is unavailable.');
87
+ return this.claudeNative.resolveInactiveStatusLogin(context, rejectedGrantGeneration, minValidityMs);
88
+ },
89
+ withNativeSource: (context, action) => {
90
+ if (!this.claudeNative) throw new Error('Claude native status authority is unavailable.');
91
+ return this.claudeNative.withNativeUsageSource(context, action);
92
+ },
93
+ claudeStatus: options.claudeStatus ?? new ClaudeAccountStatus(),
94
+ store: {
95
+ read: (accountId, loginId) => this.database.readUsage(accountId, loginId),
96
+ publishIfCurrent: async (context, previousUpdateId, next) => {
97
+ const published = await this.database.publishUsageIfCurrent(context, previousUpdateId, next);
98
+ if (published) this.broker.notifyChanged();
99
+ return published;
100
+ },
101
+ },
102
+ provider,
103
+ });
104
+ this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
105
+ if (options.claudeNative) {
106
+ this.claudeNative = new ClaudeNativeAuthority(this.database, {
107
+ ...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
108
+ });
109
+ this.broker.setClaudeRefresher(grantId => this.claudeNative!.refreshInactiveGrant(grantId));
110
+ }
47
111
  this.register();
48
112
  }
49
113
 
114
+ private runtimeBindingKey(bindingId: string, capability: string): string {
115
+ return `${bindingId}:${authSwitchBindingCapabilityHash(capability)}`;
116
+ }
117
+
118
+ private beginRuntimeBindingAccess(key: string): () => void {
119
+ if (this.runtimeBindingReleaseFences.has(key)) throw new Error('Runtime binding release is in progress.');
120
+ const activity = this.runtimeBindingActivity.get(key) ?? { active: 0, waiters: new Set<() => void>() };
121
+ activity.active++;
122
+ this.runtimeBindingActivity.set(key, activity);
123
+ let finished = false;
124
+ return () => {
125
+ if (finished) return;
126
+ finished = true;
127
+ activity.active--;
128
+ if (activity.active !== 0) return;
129
+ this.runtimeBindingActivity.delete(key);
130
+ for (const resolve of activity.waiters) resolve();
131
+ activity.waiters.clear();
132
+ };
133
+ }
134
+
135
+ private waitForRuntimeBindingAccess(key: string): Promise<void> {
136
+ const activity = this.runtimeBindingActivity.get(key);
137
+ if (!activity || activity.active === 0) return Promise.resolve();
138
+ return new Promise(resolve => activity.waiters.add(resolve));
139
+ }
140
+
141
+ private releaseRuntimeBinding(bindingId: string,
142
+ capability: string): Promise<IReq_AuthSwitchReleaseBinding['response']> {
143
+ const key = this.runtimeBindingKey(bindingId, capability);
144
+ const existing = this.runtimeBindingReleases.get(key);
145
+ if (existing) return existing;
146
+ // Capabilities are freshly random for every bind, including a successor that reuses the
147
+ // same caller label. A stale release therefore fences only its own unusable capability.
148
+ this.runtimeBindingReleaseFences.add(key);
149
+ const task = (async (): Promise<IReq_AuthSwitchReleaseBinding['response']> => {
150
+ await this.waitForRuntimeBindingAccess(key);
151
+ const state = await this.broker.releaseExternalBinding(bindingId, capability);
152
+ this.runtimeBindingReleaseFences.delete(key);
153
+ return { state };
154
+ })();
155
+ this.runtimeBindingReleases.set(key, task);
156
+ void task.finally(() => {
157
+ if (this.runtimeBindingReleases.get(key) === task) this.runtimeBindingReleases.delete(key);
158
+ // A rejected release keeps only this exact key fenced until a retry reconciles database truth.
159
+ }).catch(() => {});
160
+ return task;
161
+ }
162
+
163
+ private releaseDeviceDrain(accountId: string, operationId: string): void {
164
+ if (this.deviceDrainOwners.get(accountId) !== operationId) return;
165
+ this.deviceDrainOwners.delete(accountId);
166
+ this.drainingAccounts.delete(accountId);
167
+ }
168
+
169
+ private beginReauthOpenAi(request: IReq_AuthSwitchBeginReauth['request']): Promise<IReq_AuthSwitchBeginReauth['response']['operation']> {
170
+ let ownsReservation = false;
171
+ const prepare = async (): Promise<void> => {
172
+ if (this.closing || this.drainingAccounts.has(request.accountId)
173
+ || this.preuseAccounts.has(request.accountId)) {
174
+ throw new Error('Account runtime drain is already in progress.');
175
+ }
176
+ this.drainingAccounts.add(request.accountId);
177
+ this.deviceDrainOwners.set(request.accountId, request.operationId);
178
+ ownsReservation = true;
179
+ try { await this.drainManagedCodexAccount(request.accountId); }
180
+ catch (error) {
181
+ this.releaseDeviceDrain(request.accountId, request.operationId);
182
+ throw error;
183
+ }
184
+ };
185
+ const started = this.broker.beginReauthOpenAi(request.operationId, request.accountId,
186
+ request.loginId, request.purpose, prepare);
187
+ return started.then(operation => {
188
+ if (!ownsReservation) return operation;
189
+ if (['starting', 'pending', 'committing'].includes(operation.state)) {
190
+ void this.broker.waitOperation(operation.id).finally(() => {
191
+ this.releaseDeviceDrain(request.accountId, request.operationId);
192
+ }).catch(() => {});
193
+ } else this.releaseDeviceDrain(request.accountId, request.operationId);
194
+ return operation;
195
+ }, error => {
196
+ if (ownsReservation) this.releaseDeviceDrain(request.accountId, request.operationId);
197
+ throw error;
198
+ });
199
+ }
200
+
50
201
  private register(): void {
202
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchUsage>(
203
+ 'authswitch.authority.usage', async request => {
204
+ if (request === null || typeof request !== 'object' || Array.isArray(request)
205
+ || !Object.hasOwn(request, 'accountId') || !Object.hasOwn(request, 'loginId')
206
+ || Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
207
+ || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
208
+ || (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
209
+ if (this.closing) throw new Error('Authswitch authority is closing.');
210
+ return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
211
+ }));
212
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSwitchClaudeNative>(
213
+ 'authswitch.authority.claude.switch', async request => {
214
+ if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
215
+ || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
216
+ || request.purpose !== 'claude_host_native') invalid();
217
+ if (this.closing || !this.claudeNative) throw new Error('Claude native handoff is unavailable.');
218
+ return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
219
+ }));
220
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoff>(
221
+ 'authswitch.authority.claude.handoff', async request => {
222
+ if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
223
+ if (!this.claudeNative) throw new Error('Claude native handoff is unavailable.');
224
+ return { handoff: await this.claudeNative.getHandoff(request.operationId) };
225
+ }));
226
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchClaudeNativeHandoffs>(
227
+ 'authswitch.authority.claude.handoffs', async request => {
228
+ if (request === null || typeof request !== 'object' || Array.isArray(request)
229
+ || Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
230
+ if (!this.claudeNative) throw new Error('Claude native handoff is unavailable.');
231
+ return this.claudeNative.listHandoffs(request.after ?? null, request.limit ?? 128);
232
+ }));
51
233
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSnapshot>(
52
234
  'authswitch.authority.snapshot', async request => {
53
235
  if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
54
236
  const keys = Object.keys(request);
55
- if (keys.some(key => !['accountAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
237
+ if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
56
238
  return { snapshot: await this.broker.snapshot(request) };
57
239
  }));
240
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchDoctor>(
241
+ 'authswitch.authority.doctor', async request => {
242
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
243
+ const cursorKeys = ['accountAfter', 'loginAfter', 'codexHomeAfter', 'claudeHomeAfter',
244
+ 'claudeHandoffAfter', 'nativeHandoffAfter', 'migrationAfter'] as const;
245
+ if (Object.keys(request).some(key => ![...cursorKeys, 'limit'].includes(key as typeof cursorKeys[number] | 'limit'))
246
+ || cursorKeys.some(key => request[key] !== undefined && typeof request[key] !== 'string')
247
+ || (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
248
+ || request.limit < 1 || request.limit > 128))) invalid();
249
+ return { page: await this.doctorPage(request) };
250
+ }));
58
251
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchEvents>(
59
252
  'authswitch.authority.events', async (request, tools) => {
60
253
  if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
@@ -62,31 +255,82 @@ export class AuthSwitchAuthorityDaemon {
62
255
  }));
63
256
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginAdd>(
64
257
  'authswitch.authority.add', async request => {
65
- if (!hasKeys(request, ['providerId', 'flow']) || request.providerId !== 'openai' || request.flow !== 'device') invalid();
66
- return { operation: await this.broker.beginAddOpenAi() };
258
+ if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
259
+ || request.providerId !== 'openai' || request.flow !== 'device') invalid();
260
+ if (this.closing) throw new Error('Authswitch authority is closing.');
261
+ return { operation: await this.broker.beginAddOpenAi(request.operationId) };
67
262
  }));
68
263
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginReauth>(
69
264
  'authswitch.authority.reauth', async request => {
70
- if (!hasKeys(request, ['accountId', 'flow']) || request.flow !== 'device' || typeof request.accountId !== 'string') invalid();
71
- if (this.drainingAccounts.has(request.accountId)) throw new Error('Account runtime drain is already in progress.');
72
- this.drainingAccounts.add(request.accountId);
73
- try {
74
- await this.drainManagedCodexAccount(request.accountId);
75
- const operation = await this.broker.beginReauthOpenAi(request.accountId);
76
- void this.broker.waitOperation(operation.id).finally(() => this.drainingAccounts.delete(request.accountId)).catch(() => {});
77
- return { operation };
78
- } catch (error) { this.drainingAccounts.delete(request.accountId); throw error; }
265
+ if (!hasKeys(request, ['operationId', 'accountId', 'loginId', 'purpose', 'flow']) || request.flow !== 'device'
266
+ || typeof request.operationId !== 'string'
267
+ || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
268
+ || request.purpose !== 'openai_managed') invalid();
269
+ if (this.closing) throw new Error('Authswitch authority is closing.');
270
+ return { operation: await this.beginReauthOpenAi(request) };
79
271
  }));
80
272
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetOperation>(
81
273
  'authswitch.authority.operation', async request => {
82
274
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
83
- return { operation: this.broker.getOperation(request.operationId) };
275
+ return { operation: await this.broker.getOperation(request.operationId) };
276
+ }));
277
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchListOperations>(
278
+ 'authswitch.authority.operations', async request => {
279
+ if (request === null || typeof request !== 'object' || Array.isArray(request)
280
+ || Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
281
+ return this.broker.listOperations(request.after ?? null, request.limit ?? 128);
84
282
  }));
85
283
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelOperation>(
86
284
  'authswitch.authority.cancel', async request => {
87
285
  if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
88
286
  return { operation: await this.broker.cancelOperation(request.operationId) };
89
287
  }));
288
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchStartPreuse>(
289
+ 'authswitch.authority.preuse.start', async request => {
290
+ if (request === null || typeof request !== 'object' || Array.isArray(request)
291
+ || !['operationId', 'accountId', 'loginId', 'purpose', 'prompt'].every(key => Object.hasOwn(request, key))
292
+ || Object.keys(request).some(key => !['operationId', 'accountId', 'loginId', 'purpose', 'prompt', 'model'].includes(key))
293
+ || typeof request.operationId !== 'string' || typeof request.accountId !== 'string'
294
+ || typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
295
+ || typeof request.prompt !== 'string'
296
+ || (request.model !== undefined && typeof request.model !== 'string')) invalid();
297
+ if (this.closing) throw new Error('Authswitch authority is closing.');
298
+ const reservation: IPreuseAccountReservation = { operationId: request.operationId };
299
+ let ownsReservation = false;
300
+ const release = (): void => {
301
+ if (this.preuseAccounts.get(request.accountId) !== reservation) return;
302
+ this.preuseAccounts.delete(request.accountId);
303
+ };
304
+ try {
305
+ const operation = await this.preuse.begin(request, () => {
306
+ const owner = this.preuseAccounts.get(request.accountId);
307
+ if (this.drainingAccounts.has(request.accountId)
308
+ || (owner && owner.operationId !== request.operationId)) return 'account_busy';
309
+ if (!owner) {
310
+ this.preuseAccounts.set(request.accountId, reservation);
311
+ ownsReservation = true;
312
+ }
313
+ return 'admitted';
314
+ });
315
+ if (ownsReservation && this.preuse.hasActive(request.operationId)) {
316
+ void this.preuse.wait(request.operationId).finally(release).catch(() => {});
317
+ } else if (ownsReservation) release();
318
+ return { operation };
319
+ } catch (error) {
320
+ if (ownsReservation) release();
321
+ throw error;
322
+ }
323
+ }));
324
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetPreuse>(
325
+ 'authswitch.authority.preuse.operation', async request => {
326
+ if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
327
+ return { operation: await this.preuse.get(request.operationId) };
328
+ }));
329
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelPreuse>(
330
+ 'authswitch.authority.preuse.cancel', async request => {
331
+ if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
332
+ return { operation: await this.preuse.cancel(request.operationId) };
333
+ }));
90
334
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRenameAccount>(
91
335
  'authswitch.authority.rename', async request => {
92
336
  if (!hasKeys(request, ['accountId', 'expectedRevision', 'label']) || typeof request.accountId !== 'string'
@@ -97,7 +341,9 @@ export class AuthSwitchAuthorityDaemon {
97
341
  'authswitch.authority.remove', async request => {
98
342
  if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
99
343
  || typeof request.expectedRevision !== 'number') invalid();
100
- if (this.drainingAccounts.has(request.accountId)) throw new Error('Account runtime drain is already in progress.');
344
+ if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
345
+ throw new Error('Account runtime drain is already in progress.');
346
+ }
101
347
  this.drainingAccounts.add(request.accountId);
102
348
  try {
103
349
  await this.drainManagedCodexAccount(request.accountId);
@@ -106,8 +352,9 @@ export class AuthSwitchAuthorityDaemon {
106
352
  }));
107
353
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBindAccount>(
108
354
  'authswitch.authority.bind', async request => {
109
- if (!hasKeys(request, ['accountId', 'runtime', 'scopeId', 'incarnationId'])
110
- || typeof request.accountId !== 'string' || typeof request.scopeId !== 'string'
355
+ if (!hasKeys(request, ['accountId', 'loginId', 'purpose', 'runtime', 'scopeId', 'incarnationId'])
356
+ || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
357
+ || request.purpose !== 'openai_managed' || typeof request.scopeId !== 'string'
111
358
  || typeof request.incarnationId !== 'string') invalid();
112
359
  return this.broker.bindAccount(request);
113
360
  }));
@@ -117,19 +364,133 @@ export class AuthSwitchAuthorityDaemon {
117
364
  || !['bindingId', 'capability', 'minValidityMs'].every(key => Object.hasOwn(request, key))
118
365
  || Object.keys(request).some(key => !['bindingId', 'capability', 'minValidityMs',
119
366
  'rejectedGrantGeneration'].includes(key))
120
- || typeof request.bindingId !== 'string' || typeof request.capability !== 'string'
367
+ || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
121
368
  || typeof request.minValidityMs !== 'number'
122
369
  || (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
123
- return this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
124
- request.rejectedGrantGeneration);
370
+ if (this.closing) throw new Error('Authswitch authority is closing.');
371
+ const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
372
+ try {
373
+ return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
374
+ request.rejectedGrantGeneration);
375
+ } finally { finish(); }
376
+ }));
377
+ this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchReleaseBinding>(
378
+ 'authswitch.authority.release', async request => {
379
+ if (!hasKeys(request, ['bindingId', 'capability'])
380
+ || !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
381
+ if (this.closing) throw new Error('Authswitch authority is closing.');
382
+ return this.releaseRuntimeBinding(request.bindingId, request.capability);
125
383
  }));
126
384
  }
127
385
 
386
+ private async doctorPage(request: IReq_AuthSwitchDoctor['request']): Promise<IAuthSwitchDoctorPage> {
387
+ const stored = await this.database.doctorPage({
388
+ accountAfter: request.accountAfter ?? null,
389
+ loginAfter: request.loginAfter ?? null,
390
+ codexHomeAfter: request.codexHomeAfter ?? null,
391
+ claudeHomeAfter: request.claudeHomeAfter ?? null,
392
+ claudeHandoffAfter: request.claudeHandoffAfter ?? null,
393
+ nativeHandoffAfter: request.nativeHandoffAfter ?? null,
394
+ migrationAfter: request.migrationAfter ?? null,
395
+ }, request.limit ?? 128);
396
+ const socketPaths = stored.codexHomes.flatMap(home => home.activeRun ? [home.activeRun.socketPath] : []);
397
+ let processInspection: Awaited<ReturnType<typeof observeManagedCodexProcesses>> | null = null;
398
+ try { processInspection = await observeManagedCodexProcesses(socketPaths); }
399
+ catch { /* A fixed diagnostic category is safer than exposing a host-process error. */ }
400
+ const processObservedAt = new Date().toISOString();
401
+ const managedCodex = stored.codexHomes.map(home => {
402
+ const activeRun = home.activeRun;
403
+ const durable: IAuthSwitchDoctorPage['managedCodex'][number]['durable'] = activeRun === null
404
+ ? { state: 'idle', runId: null, pid: null, startedAt: null }
405
+ : { state: activeRun.pid === null || activeRun.startedAt === null ? 'launch_prepared' : 'registered',
406
+ runId: activeRun.id, pid: activeRun.pid, startedAt: activeRun.startedAt };
407
+ const daemonState = this.managedCodexStates.get(home.scopeId) ?? 'untracked';
408
+ const assignment = daemonState === 'untracked'
409
+ ? 'untracked' as const
410
+ : this.managedCodexAccountIds.get(home.scopeId) === home.accountId && this.managedCodexHomes.has(home.id)
411
+ ? 'matches' as const : 'mismatch' as const;
412
+ let process: IAuthSwitchDoctorPage['managedCodex'][number]['process'];
413
+ if (!activeRun) {
414
+ process = { state: 'not_expected', identity: 'not_applicable', pid: null, startedAt: null,
415
+ observedAt: processObservedAt, problem: 'none' };
416
+ } else if (!processInspection) {
417
+ process = { state: 'unknown', identity: 'unknown', pid: null, startedAt: null,
418
+ observedAt: processObservedAt, problem: 'inspection_failed' };
419
+ } else {
420
+ const observed = processInspection.get(activeRun.socketPath);
421
+ if (!observed || observed.state === 'absent') {
422
+ process = { state: 'absent', identity: 'not_applicable', pid: null, startedAt: null,
423
+ observedAt: processObservedAt, problem: 'none' };
424
+ } else if (observed.state === 'multiple_claimants') {
425
+ process = { state: 'unknown', identity: 'unknown', pid: null, startedAt: null,
426
+ observedAt: processObservedAt, problem: 'multiple_claimants' };
427
+ } else {
428
+ const identity = activeRun.pid === null || activeRun.startedAt === null
429
+ ? 'unregistered' as const
430
+ : activeRun.pid === observed.pid && activeRun.startedAt === observed.startedAt
431
+ ? 'matches' as const : 'mismatch' as const;
432
+ process = { state: 'observed', identity, pid: observed.pid, startedAt: observed.startedAt,
433
+ observedAt: processObservedAt, problem: 'none' };
434
+ }
435
+ }
436
+ const health = process.state === 'unknown'
437
+ ? 'unknown' as const
438
+ : daemonState === 'degraded' || assignment === 'mismatch'
439
+ || (activeRun !== null && (daemonState === 'untracked' || process.state === 'absent'
440
+ || process.identity === 'mismatch'))
441
+ ? 'degraded' as const : 'healthy' as const;
442
+ return { homeId: home.id, accountId: home.accountId, loginId: home.grantId, scopeId: home.scopeId,
443
+ health, durable, daemon: { state: daemonState, assignment }, process };
444
+ });
445
+ const observedAt = new Date().toISOString();
446
+ return {
447
+ observedAt,
448
+ daemon: { state: this.closing ? 'closing' : 'ready',
449
+ claudeNativeAuthority: this.claudeNative ? 'configured' : 'not_configured', observedAt },
450
+ database: { state: 'available', observedAt: stored.observedAt,
451
+ epoch: stored.meta.epoch, revision: stored.meta.revision },
452
+ accounts: stored.accounts.map(account => ({ id: account.id, providerId: account.providerId,
453
+ removed: account.removed, statusObservedAt: account.statusObservedAt,
454
+ evidence: 'stored', liveCheck: 'not_checked' })),
455
+ logins: stored.logins.map(login => ({ id: login.id, accountId: login.accountId,
456
+ providerId: login.providerId, purpose: login.purpose, owner: login.owner, state: login.state,
457
+ problem: login.problem, grantGeneration: login.grantGeneration,
458
+ authorizationGeneration: login.authorizationGeneration, accessExpiresAt: login.accessExpiresAt,
459
+ retryAt: login.retryAt, statusObservedAt: login.statusObservedAt,
460
+ evidence: 'stored', liveCheck: 'not_checked' })),
461
+ managedCodex,
462
+ claudeHomes: stored.claudeHomes.map(home => ({ id: home.id, activeAccountId: home.activeAccountId,
463
+ activeLoginId: home.activeGrantId, pendingOperationId: home.pendingOperationId, status: home.status,
464
+ revision: home.revision, createdAt: home.createdAt })),
465
+ claudeHandoffs: stored.claudeHandoffs.map(handoff => ({ id: handoff.id, homeId: handoff.homeId,
466
+ outgoingAccountId: handoff.outgoingAccountId, outgoingLoginId: handoff.outgoingGrantId,
467
+ incomingAccountId: handoff.incomingAccountId, incomingLoginId: handoff.incomingGrantId,
468
+ phase: handoff.phase, problem: handoff.problem, runningEffectiveAuth: handoff.runningEffectiveAuth,
469
+ startedAt: handoff.startedAt, updatedAt: handoff.updatedAt, revision: handoff.revision })),
470
+ nativeHandoffs: stored.nativeHandoffs.map(handoff => ({ id: handoff.id, accountId: handoff.accountId,
471
+ loginId: handoff.grantId, nativeHomeId: handoff.nativeHomeId, direction: handoff.direction,
472
+ phase: handoff.phase, operationId: handoff.operationId, revision: handoff.revision,
473
+ statusObservedAt: handoff.statusObservedAt })),
474
+ migrations: stored.migrations.map(migration => ({ id: migration.id, sourceKind: migration.sourceKind,
475
+ status: migration.status, accountId: migration.accountId, loginId: migration.grantId,
476
+ revision: migration.revision, statusObservedAt: migration.statusObservedAt })),
477
+ nextAccountCursor: stored.nextAccountCursor,
478
+ nextLoginCursor: stored.nextLoginCursor,
479
+ nextCodexHomeCursor: stored.nextCodexHomeCursor,
480
+ nextClaudeHomeCursor: stored.nextClaudeHomeCursor,
481
+ nextClaudeHandoffCursor: stored.nextClaudeHandoffCursor,
482
+ nextNativeHandoffCursor: stored.nextNativeHandoffCursor,
483
+ nextMigrationCursor: stored.nextMigrationCursor,
484
+ };
485
+ }
486
+
128
487
  public async start(): Promise<void> {
129
488
  if (this.managementServer || this.runtimeServer) throw new Error('Authswitch authority daemon is already started.');
130
489
  await this.database.start();
131
490
  try {
132
491
  await this.broker.start();
492
+ await this.preuse.start();
493
+ if (this.claudeNative) await this.claudeNative.start();
133
494
  const managementDirectory = plugins.path.dirname(this.options.authoritySocketPath);
134
495
  const runtimeDirectory = plugins.path.dirname(this.options.runtimeSocketPath);
135
496
  const databaseDirectory = plugins.path.dirname(this.options.socketPath);
@@ -148,16 +509,37 @@ export class AuthSwitchAuthorityDaemon {
148
509
  }
149
510
  }
150
511
 
151
- /** Trusted in-process SDK admission; management RPC exposure belongs to the later CLI cutover. */
512
+ /** The account fence excludes new admissions while durable detached runs are checked. */
152
513
  private async drainManagedCodexAccount(accountId: string): Promise<void> {
153
514
  const scopes = [...this.managedCodexAccountIds]
154
515
  .filter(([, assignedAccountId]) => assignedAccountId === accountId).map(([scopeId]) => scopeId);
155
- const results = await Promise.allSettled(scopes.map(scopeId => this.stopManagedCodex(scopeId)));
156
- const failed = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
157
- if (failed.length) throw new AggregateError(failed.map(result => result.reason), 'Managed Codex account drain failed.');
516
+ if (scopes.length) throw new Error('Managed Codex may still have active work; stop it explicitly after work finishes before changing this account.');
517
+ let after: string | null = null;
518
+ while (true) {
519
+ const homes = await this.database.codexHomesForAccount(accountId, after);
520
+ for (const home of homes) {
521
+ const run = home.activeRun;
522
+ if (!run) continue;
523
+ const survivor = await findManagedCodexProcess(run.socketPath);
524
+ if (survivor) throw new Error('A managed Codex app-server still owns this account; stop its work before changing the account.');
525
+ if (run.pid === null || run.startedAt === null) {
526
+ throw new Error('A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
527
+ }
528
+ try {
529
+ process.kill(run.pid, 0);
530
+ throw new Error('A managed Codex process identity is still present; reconcile it before changing the account.');
531
+ } catch (error) {
532
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')) throw error;
533
+ }
534
+ await this.database.changeCodexRun(home.id, run.id, null, plugins.crypto.randomUUID());
535
+ }
536
+ if (homes.length < 128) break;
537
+ after = homes.at(-1)!.id;
538
+ }
158
539
  }
159
540
 
160
- public async startManagedCodex(input: { accountId: string; scopeId: string; workspaceDirectory: string;
541
+ public async startManagedCodex(input: { accountId: string; loginId: string; purpose: 'openai_managed';
542
+ scopeId: string; workspaceDirectory: string;
161
543
  codexHomeDirectory: string;
162
544
  executable?: string; onNotification?: IAuthSwitchManagedCodexOptions['onNotification'];
163
545
  onServerRequest?: IAuthSwitchManagedCodexOptions['onServerRequest'] }): Promise<AuthSwitchManagedCodex> {
@@ -173,6 +555,7 @@ export class AuthSwitchAuthorityDaemon {
173
555
  }
174
556
  this.managedCodexHomes.add(homeId);
175
557
  this.managedCodexAccountIds.set(input.scopeId, input.accountId);
558
+ this.managedCodexStates.set(input.scopeId, 'starting');
176
559
  const runId = plugins.crypto.randomUUID();
177
560
  const task = (async () => {
178
561
  let bound: Awaited<ReturnType<typeof this.broker.bindAccount>> | undefined;
@@ -193,7 +576,8 @@ export class AuthSwitchAuthorityDaemon {
193
576
  throw new Error('Managed Codex cannot adopt an active native Codex home.');
194
577
  }
195
578
  const existing = await this.database.readCodexHome(homeId);
196
- if (existing && (existing.accountId !== input.accountId || existing.scopeId !== input.scopeId)) {
579
+ if (existing && (existing.accountId !== input.accountId || existing.grantId !== input.loginId
580
+ || existing.scopeId !== input.scopeId)) {
197
581
  throw new Error('Managed Codex home already belongs to another account or scope.');
198
582
  }
199
583
  let homeExists = false;
@@ -210,9 +594,10 @@ export class AuthSwitchAuthorityDaemon {
210
594
  if (!existing && homeExists && (await plugins.fs.promises.readdir(home)).length) {
211
595
  throw new Error('An existing Codex home needs explicit native-owner handoff before enrollment.');
212
596
  }
213
- await this.database.reserveCodexHome(input.accountId, input.scopeId, homeId, plugins.crypto.randomUUID());
597
+ await this.database.reserveCodexHome(input.accountId, input.loginId, input.scopeId, homeId, plugins.crypto.randomUUID());
214
598
  if (!homeExists) await plugins.fs.promises.mkdir(home, { mode: 0o700 });
215
- bound = await this.broker.bindAccount({ accountId: input.accountId, runtime: 'codex',
599
+ bound = await this.broker.bindAccount({ accountId: input.accountId, loginId: input.loginId,
600
+ purpose: input.purpose, runtime: 'codex',
216
601
  scopeId: input.scopeId, incarnationId: plugins.crypto.randomUUID() });
217
602
  runtime = new AuthSwitchManagedCodex({
218
603
  runtimeDirectory: plugins.path.join(plugins.path.dirname(plugins.path.dirname(this.options.authoritySocketPath)), 'managed-codex'),
@@ -221,7 +606,8 @@ export class AuthSwitchAuthorityDaemon {
221
606
  broker: this.broker, bindingId: bound.binding.id, capability: bound.capability,
222
607
  onPreparedRun: async socketPath => {
223
608
  const assigned = await this.database.readCodexHome(homeId);
224
- if (!assigned || assigned.accountId !== input.accountId || assigned.scopeId !== input.scopeId) {
609
+ if (!assigned || assigned.accountId !== input.accountId || assigned.grantId !== input.loginId
610
+ || assigned.scopeId !== input.scopeId) {
225
611
  throw new Error('Managed Codex home assignment changed.');
226
612
  }
227
613
  if (assigned.activeRun) {
@@ -250,6 +636,9 @@ export class AuthSwitchAuthorityDaemon {
250
636
  });
251
637
  await runtime.start();
252
638
  if (this.closing) throw new Error('Managed Codex admission closed during startup.');
639
+ if (this.managedCodexStates.get(input.scopeId) !== 'stopping') {
640
+ this.managedCodexStates.set(input.scopeId, 'ready');
641
+ }
253
642
  return { runtime, bindingId: bound.binding.id, capability: bound.capability, homeId, runId };
254
643
  } catch (error) {
255
644
  const cleanupErrors: unknown[] = [];
@@ -262,8 +651,12 @@ export class AuthSwitchAuthorityDaemon {
262
651
  if (cleanupErrors.length === 0) {
263
652
  this.managedCodexHomes.delete(homeId);
264
653
  this.managedCodexAccountIds.delete(input.scopeId);
654
+ this.managedCodexStates.delete(input.scopeId);
655
+ }
656
+ if (cleanupErrors.length) {
657
+ this.managedCodexStates.set(input.scopeId, 'degraded');
658
+ throw new AggregateError([error, ...cleanupErrors], 'Managed Codex admission cleanup failed.');
265
659
  }
266
- if (cleanupErrors.length) throw new AggregateError([error, ...cleanupErrors], 'Managed Codex admission cleanup failed.');
267
660
  throw error;
268
661
  }
269
662
  })();
@@ -278,10 +671,15 @@ export class AuthSwitchAuthorityDaemon {
278
671
  const task = this.managedCodex.get(scopeId);
279
672
  if (!task) return Promise.resolve();
280
673
  this.managedCodex.delete(scopeId);
674
+ this.managedCodexStates.set(scopeId, 'stopping');
281
675
  const stopping = (async () => {
282
676
  let entry: Awaited<typeof task>;
283
677
  try { entry = await task; }
284
- catch { /* Failed admission already closed its process and revoked its binding. */ return; }
678
+ catch {
679
+ /* Failed admission already closed its process and revoked its binding. */
680
+ if (this.managedCodexStates.get(scopeId) !== 'degraded') this.managedCodexStates.delete(scopeId);
681
+ return;
682
+ }
285
683
  const { runtime, bindingId, capability, homeId, runId } = entry;
286
684
  const errors: unknown[] = [];
287
685
  try { await runtime.close(); } catch (error) { errors.push(error); }
@@ -289,9 +687,13 @@ export class AuthSwitchAuthorityDaemon {
289
687
  await this.database.changeCodexRun(homeId, runId, null, plugins.crypto.randomUUID());
290
688
  this.managedCodexHomes.delete(homeId);
291
689
  this.managedCodexAccountIds.delete(scopeId);
690
+ this.managedCodexStates.delete(scopeId);
292
691
  } catch (error) { errors.push(error); }
293
692
  try { await this.broker.revokeBinding(bindingId, capability); } catch (error) { errors.push(error); }
294
- if (errors.length) throw new AggregateError(errors, 'Managed Codex shutdown was incomplete.');
693
+ if (errors.length) {
694
+ this.managedCodexStates.set(scopeId, 'degraded');
695
+ throw new AggregateError(errors, 'Managed Codex shutdown was incomplete.');
696
+ }
295
697
  })();
296
698
  this.managedCodexStops.set(scopeId, stopping);
297
699
  void stopping.finally(() => { if (this.managedCodexStops.get(scopeId) === stopping) this.managedCodexStops.delete(scopeId); }).catch(() => {});
@@ -379,14 +781,20 @@ export class AuthSwitchAuthorityDaemon {
379
781
  public async close(): Promise<void> {
380
782
  this.closing = true;
381
783
  const errors: unknown[] = [];
784
+ try { await this.preuse.close(); } catch (error) { errors.push(error); }
382
785
  for (const scopeId of [...this.managedCodex.keys()]) {
383
786
  try { await this.stopManagedCodex(scopeId); } catch (error) { errors.push(error); }
384
787
  }
385
788
  for (const stopping of [...this.managedCodexStops.values()]) {
386
789
  try { await stopping; } catch (error) { errors.push(error); }
387
790
  }
791
+ for (const release of [...this.runtimeBindingReleases.values()]) {
792
+ try { await release; } catch (error) { errors.push(error); }
793
+ }
388
794
  errors.push(...this.managedCodexFailures);
795
+ try { await this.usage.close(); } catch (error) { errors.push(error); }
389
796
  try { await this.broker.close(); } catch (error) { errors.push(error); }
797
+ if (this.claudeNative) try { await this.claudeNative.close(); } catch (error) { errors.push(error); }
390
798
  const management = this.managementServer;
391
799
  const runtime = this.runtimeServer;
392
800
  this.managementServer = undefined;