@modelprofile.com/authswitch 6.4.0 → 6.5.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.
@@ -47,6 +47,21 @@ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promi
47
47
  });
48
48
  };
49
49
 
50
+ /** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
51
+ export interface IAuthSwitchSubscriptionStatus {
52
+ state: 'current' | 'unavailable' | 'closed';
53
+ reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error';
54
+ epoch: string | null;
55
+ revision: number | null;
56
+ lastVerifiedAt: string | null;
57
+ }
58
+
59
+ export interface IAuthSwitchSubscriptionOptions {
60
+ onStatus?: (status: IAuthSwitchSubscriptionStatus) => void | Promise<void>;
61
+ /** Bounded long-poll interval; defaults to 30 seconds. */
62
+ heartbeatMs?: number;
63
+ }
64
+
50
65
  /** Node-only client for the per-user daemon. Each request has its own bounded Unix connection. */
51
66
  export class AuthSwitchClient {
52
67
  private readonly target: plugins.typedrequest.TypedTarget;
@@ -105,39 +120,93 @@ export class AuthSwitchClient {
105
120
  return this.request<IReq_AuthSwitchEvents>('authswitch.authority.events', { epoch, afterRevision, waitMs }, waitMs + 5_000, false, signal);
106
121
  }
107
122
 
108
- /** Always obtains a fresh snapshot after reconnect or an event-log gap. */
123
+ /** Snapshot callback runs after every known change; onStatus invalidates it on disconnect, resync or abort. */
109
124
  public async subscribe(onSnapshot: (snapshot: IAuthSwitchSnapshot) => void | Promise<void>,
110
- onEvent: (event: IAuthSwitchAccountEvent) => void | Promise<void>, signal: AbortSignal): Promise<void> {
125
+ onEvent: (event: IAuthSwitchAccountEvent) => void | Promise<void>, signal: AbortSignal,
126
+ options: IAuthSwitchSubscriptionOptions = {}): Promise<void> {
127
+ const heartbeatMs = options.heartbeatMs ?? 30_000;
128
+ if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 50 || heartbeatMs > 30_000) {
129
+ throw new Error('Authswitch subscription heartbeat must be 50–30000 ms.');
130
+ }
111
131
  const retry = () => new Promise<void>(resolve => {
112
132
  const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, 250);
113
133
  const aborted = () => { clearTimeout(timer); resolve(); };
114
134
  signal.addEventListener('abort', aborted, { once: true });
115
135
  });
116
136
  let snapshot: IAuthSwitchSnapshot | undefined;
117
- while (!signal.aborted) {
118
- if (!snapshot) {
119
- try { snapshot = await this.snapshotAll({ signal }); }
137
+ let available = false;
138
+ let lastVerifiedAt: string | null = null;
139
+ let lastEpoch: string | null = null;
140
+ let lastRevision: number | null = null;
141
+ const status = async (state: IAuthSwitchSubscriptionStatus['state'],
142
+ reason: IAuthSwitchSubscriptionStatus['reason']): Promise<void> => {
143
+ await options.onStatus?.({ state, reason, epoch: lastEpoch, revision: lastRevision, lastVerifiedAt });
144
+ };
145
+ const invalidate = async (reason: 'disconnect' | 'resync'): Promise<void> => {
146
+ snapshot = undefined;
147
+ if (available) { available = false; await status('unavailable', reason); }
148
+ };
149
+ try {
150
+ if (!signal.aborted) await status('unavailable', 'initial');
151
+ while (!signal.aborted) {
152
+ if (!snapshot) {
153
+ let fresh: IAuthSwitchSnapshot;
154
+ try { fresh = await this.snapshotAll({ signal }); }
155
+ catch {
156
+ if (signal.aborted) break;
157
+ await invalidate('disconnect');
158
+ await retry();
159
+ continue;
160
+ }
161
+ snapshot = fresh;
162
+ await onSnapshot(fresh);
163
+ available = true;
164
+ lastEpoch = fresh.epoch;
165
+ lastRevision = fresh.revision;
166
+ lastVerifiedAt = new Date().toISOString();
167
+ await status('current', 'snapshot');
168
+ }
169
+ let result: IReq_AuthSwitchEvents['response'];
170
+ try { result = await this.events(snapshot.epoch, snapshot.revision, heartbeatMs, signal); }
120
171
  catch {
121
172
  if (signal.aborted) break;
173
+ await invalidate('disconnect');
122
174
  await retry();
123
175
  continue;
124
176
  }
125
- await onSnapshot(snapshot);
126
- }
127
- let result: IReq_AuthSwitchEvents['response'];
128
- try { result = await this.events(snapshot.epoch, snapshot.revision, 30_000, signal); }
129
- catch {
130
- if (signal.aborted) break;
131
- snapshot = undefined;
132
- await retry();
133
- continue;
134
- }
135
- if (result.resyncRequired || result.epoch !== snapshot.epoch) {
136
- snapshot = undefined;
137
- continue;
177
+ if (result.resyncRequired || result.epoch !== snapshot.epoch
178
+ || (result.events.length === 0 && result.revision !== snapshot.revision)) {
179
+ await invalidate('resync');
180
+ continue;
181
+ }
182
+ if (result.events.length) {
183
+ let fresh: IAuthSwitchSnapshot;
184
+ try { fresh = await this.snapshotAll({ signal }); }
185
+ catch {
186
+ if (signal.aborted) break;
187
+ await invalidate('disconnect');
188
+ await retry();
189
+ continue;
190
+ }
191
+ if (fresh.epoch !== result.epoch || fresh.revision < result.revision) {
192
+ await invalidate('resync');
193
+ continue;
194
+ }
195
+ snapshot = fresh;
196
+ await onSnapshot(fresh);
197
+ for (const event of result.events) await onEvent(event);
198
+ lastEpoch = fresh.epoch;
199
+ lastRevision = fresh.revision;
200
+ lastVerifiedAt = new Date().toISOString();
201
+ await status('current', 'event');
202
+ } else {
203
+ lastVerifiedAt = new Date().toISOString();
204
+ await status('current', 'heartbeat');
205
+ }
138
206
  }
139
- for (const event of result.events) await onEvent(event);
140
- snapshot = { ...snapshot, revision: result.revision };
207
+ } finally {
208
+ available = false;
209
+ await status('closed', signal.aborted ? 'abort' : 'consumer_error');
141
210
  }
142
211
  }
143
212
 
@@ -10,6 +10,7 @@ import { AuthSwitchAuthorityBroker, type IAuthSwitchAuthorityBrokerOptions } fro
10
10
  import { AuthSwitchAuthorityDatabase, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
11
11
  import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
12
12
  maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
13
+ import { AuthSwitchManagedCodex, findManagedCodexProcess, type IAuthSwitchManagedCodexOptions } from './classes.codexmanaged.js';
13
14
 
14
15
  export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityDatabaseOptions {
15
16
  authoritySocketPath: string;
@@ -31,6 +32,14 @@ export class AuthSwitchAuthorityDaemon {
31
32
  private managementServer?: plugins.net.Server;
32
33
  private runtimeServer?: plugins.net.Server;
33
34
  private readonly sockets = new Set<plugins.net.Socket>();
35
+ private readonly managedCodex = new Map<string, Promise<{ runtime: AuthSwitchManagedCodex; bindingId: string;
36
+ capability: string; homeId: string; runId: string }>>();
37
+ private readonly managedCodexHomes = new Set<string>();
38
+ private readonly managedCodexAccountIds = new Map<string, string>();
39
+ private readonly drainingAccounts = new Set<string>();
40
+ private readonly managedCodexStops = new Map<string, Promise<void>>();
41
+ private readonly managedCodexFailures: unknown[] = [];
42
+ private closing = false;
34
43
 
35
44
  constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
36
45
  this.database = new AuthSwitchAuthorityDatabase(options);
@@ -59,7 +68,14 @@ export class AuthSwitchAuthorityDaemon {
59
68
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginReauth>(
60
69
  'authswitch.authority.reauth', async request => {
61
70
  if (!hasKeys(request, ['accountId', 'flow']) || request.flow !== 'device' || typeof request.accountId !== 'string') invalid();
62
- return { operation: await this.broker.beginReauthOpenAi(request.accountId) };
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; }
63
79
  }));
64
80
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetOperation>(
65
81
  'authswitch.authority.operation', async request => {
@@ -81,7 +97,12 @@ export class AuthSwitchAuthorityDaemon {
81
97
  'authswitch.authority.remove', async request => {
82
98
  if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
83
99
  || typeof request.expectedRevision !== 'number') invalid();
84
- return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
100
+ if (this.drainingAccounts.has(request.accountId)) throw new Error('Account runtime drain is already in progress.');
101
+ this.drainingAccounts.add(request.accountId);
102
+ try {
103
+ await this.drainManagedCodexAccount(request.accountId);
104
+ return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
105
+ } finally { this.drainingAccounts.delete(request.accountId); }
85
106
  }));
86
107
  this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBindAccount>(
87
108
  'authswitch.authority.bind', async request => {
@@ -92,10 +113,15 @@ export class AuthSwitchAuthorityDaemon {
92
113
  }));
93
114
  this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchResolveAccess>(
94
115
  'authswitch.authority.resolveAccess', async request => {
95
- if (!hasKeys(request, ['bindingId', 'capability', 'minValidityMs'])
116
+ if ((typeof request !== 'object' || request === null || Array.isArray(request))
117
+ || !['bindingId', 'capability', 'minValidityMs'].every(key => Object.hasOwn(request, key))
118
+ || Object.keys(request).some(key => !['bindingId', 'capability', 'minValidityMs',
119
+ 'rejectedGrantGeneration'].includes(key))
96
120
  || typeof request.bindingId !== 'string' || typeof request.capability !== 'string'
97
- || typeof request.minValidityMs !== 'number') invalid();
98
- return this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs);
121
+ || typeof request.minValidityMs !== 'number'
122
+ || (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
123
+ return this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
124
+ request.rejectedGrantGeneration);
99
125
  }));
100
126
  }
101
127
 
@@ -122,6 +148,156 @@ export class AuthSwitchAuthorityDaemon {
122
148
  }
123
149
  }
124
150
 
151
+ /** Trusted in-process SDK admission; management RPC exposure belongs to the later CLI cutover. */
152
+ private async drainManagedCodexAccount(accountId: string): Promise<void> {
153
+ const scopes = [...this.managedCodexAccountIds]
154
+ .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.');
158
+ }
159
+
160
+ public async startManagedCodex(input: { accountId: string; scopeId: string; workspaceDirectory: string;
161
+ codexHomeDirectory: string;
162
+ executable?: string; onNotification?: IAuthSwitchManagedCodexOptions['onNotification'];
163
+ onServerRequest?: IAuthSwitchManagedCodexOptions['onServerRequest'] }): Promise<AuthSwitchManagedCodex> {
164
+ if (!plugins.path.isAbsolute(input.codexHomeDirectory)
165
+ || plugins.path.normalize(input.codexHomeDirectory) !== input.codexHomeDirectory) {
166
+ throw new Error('Managed Codex requires a normalized absolute state home.');
167
+ }
168
+ const homeId = plugins.crypto.createHash('sha256').update(JSON.stringify(['authswitch-codex-home-v1', input.codexHomeDirectory])).digest('hex');
169
+ if (this.closing || !this.managementServer || !this.runtimeServer
170
+ || this.managedCodex.has(input.scopeId) || this.managedCodexStops.has(input.scopeId)
171
+ || this.managedCodexHomes.has(homeId) || this.drainingAccounts.has(input.accountId)) {
172
+ throw new Error('Managed Codex scope is unavailable.');
173
+ }
174
+ this.managedCodexHomes.add(homeId);
175
+ this.managedCodexAccountIds.set(input.scopeId, input.accountId);
176
+ const runId = plugins.crypto.randomUUID();
177
+ const task = (async () => {
178
+ let bound: Awaited<ReturnType<typeof this.broker.bindAccount>> | undefined;
179
+ let runtime: AuthSwitchManagedCodex | undefined;
180
+ let claimed = false;
181
+ try {
182
+ const home = input.codexHomeDirectory;
183
+ const parent = plugins.path.dirname(home);
184
+ const parentStat = await plugins.fs.promises.lstat(parent);
185
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink() || parentStat.uid !== process.getuid?.()
186
+ || (parentStat.mode & 0o077) !== 0 || await plugins.fs.promises.realpath(parent) !== parent) {
187
+ throw new Error('Managed Codex home parent must be private and owned by this user.');
188
+ }
189
+ const nativeHomes = [plugins.path.join(plugins.os.homedir(), '.codex'), process.env.CODEX_HOME]
190
+ .filter((value): value is string => typeof value === 'string' && plugins.path.isAbsolute(value));
191
+ if (nativeHomes.some(native => home === native || home.startsWith(native + plugins.path.sep)
192
+ || native.startsWith(home + plugins.path.sep))) {
193
+ throw new Error('Managed Codex cannot adopt an active native Codex home.');
194
+ }
195
+ const existing = await this.database.readCodexHome(homeId);
196
+ if (existing && (existing.accountId !== input.accountId || existing.scopeId !== input.scopeId)) {
197
+ throw new Error('Managed Codex home already belongs to another account or scope.');
198
+ }
199
+ let homeExists = false;
200
+ try {
201
+ const stat = await plugins.fs.promises.lstat(home);
202
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()
203
+ || (stat.mode & 0o077) !== 0 || await plugins.fs.promises.realpath(home) !== home) {
204
+ throw new Error('Managed Codex home is not a private owned directory.');
205
+ }
206
+ homeExists = true;
207
+ } catch (error) {
208
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) throw error;
209
+ }
210
+ if (!existing && homeExists && (await plugins.fs.promises.readdir(home)).length) {
211
+ throw new Error('An existing Codex home needs explicit native-owner handoff before enrollment.');
212
+ }
213
+ await this.database.reserveCodexHome(input.accountId, input.scopeId, homeId, plugins.crypto.randomUUID());
214
+ if (!homeExists) await plugins.fs.promises.mkdir(home, { mode: 0o700 });
215
+ bound = await this.broker.bindAccount({ accountId: input.accountId, runtime: 'codex',
216
+ scopeId: input.scopeId, incarnationId: plugins.crypto.randomUUID() });
217
+ runtime = new AuthSwitchManagedCodex({
218
+ runtimeDirectory: plugins.path.join(plugins.path.dirname(plugins.path.dirname(this.options.authoritySocketPath)), 'managed-codex'),
219
+ codexHomeDirectory: home, workspaceDirectory: input.workspaceDirectory, executable: input.executable,
220
+ onNotification: input.onNotification, onServerRequest: input.onServerRequest,
221
+ broker: this.broker, bindingId: bound.binding.id, capability: bound.capability,
222
+ onPreparedRun: async socketPath => {
223
+ const assigned = await this.database.readCodexHome(homeId);
224
+ if (!assigned || assigned.accountId !== input.accountId || assigned.scopeId !== input.scopeId) {
225
+ throw new Error('Managed Codex home assignment changed.');
226
+ }
227
+ if (assigned.activeRun) {
228
+ const survivor = await findManagedCodexProcess(assigned.activeRun.socketPath);
229
+ if (survivor) throw new Error('Managed Codex home still has a live app-server owner.');
230
+ await this.database.changeCodexRun(homeId, assigned.activeRun.id, null, plugins.crypto.randomUUID());
231
+ }
232
+ await this.database.changeCodexRun(homeId, null,
233
+ { id: runId, socketPath, pid: null, startedAt: null }, plugins.crypto.randomUUID());
234
+ claimed = true;
235
+ },
236
+ onChildSpawned: async (pid, startedAt) => {
237
+ const assigned = await this.database.readCodexHome(homeId);
238
+ if (assigned?.activeRun?.id !== runId || assigned.activeRun.pid !== null) {
239
+ throw new Error('Managed Codex run registration changed before process verification.');
240
+ }
241
+ await this.database.changeCodexRun(homeId, runId,
242
+ { ...assigned.activeRun, pid, startedAt }, plugins.crypto.randomUUID());
243
+ },
244
+ onUnavailable: () => {
245
+ void this.stopManagedCodex(input.scopeId).catch(error => {
246
+ this.managedCodexFailures.push(error);
247
+ process.stderr.write('Managed Codex cleanup failed after its control connection closed.\n');
248
+ });
249
+ },
250
+ });
251
+ await runtime.start();
252
+ if (this.closing) throw new Error('Managed Codex admission closed during startup.');
253
+ return { runtime, bindingId: bound.binding.id, capability: bound.capability, homeId, runId };
254
+ } catch (error) {
255
+ const cleanupErrors: unknown[] = [];
256
+ if (runtime) try { await runtime.close(); } catch (cleanupError) { cleanupErrors.push(cleanupError); }
257
+ if (claimed && cleanupErrors.length === 0) try {
258
+ await this.database.changeCodexRun(homeId, runId, null, plugins.crypto.randomUUID());
259
+ } catch (cleanupError) { cleanupErrors.push(cleanupError); }
260
+ if (bound) try { await this.broker.revokeBinding(bound.binding.id, bound.capability); }
261
+ catch (cleanupError) { cleanupErrors.push(cleanupError); }
262
+ if (cleanupErrors.length === 0) {
263
+ this.managedCodexHomes.delete(homeId);
264
+ this.managedCodexAccountIds.delete(input.scopeId);
265
+ }
266
+ if (cleanupErrors.length) throw new AggregateError([error, ...cleanupErrors], 'Managed Codex admission cleanup failed.');
267
+ throw error;
268
+ }
269
+ })();
270
+ this.managedCodex.set(input.scopeId, task);
271
+ try { return (await task).runtime; }
272
+ catch (error) { this.managedCodex.delete(input.scopeId); throw error; }
273
+ }
274
+
275
+ public stopManagedCodex(scopeId: string): Promise<void> {
276
+ const existing = this.managedCodexStops.get(scopeId);
277
+ if (existing) return existing;
278
+ const task = this.managedCodex.get(scopeId);
279
+ if (!task) return Promise.resolve();
280
+ this.managedCodex.delete(scopeId);
281
+ const stopping = (async () => {
282
+ let entry: Awaited<typeof task>;
283
+ try { entry = await task; }
284
+ catch { /* Failed admission already closed its process and revoked its binding. */ return; }
285
+ const { runtime, bindingId, capability, homeId, runId } = entry;
286
+ const errors: unknown[] = [];
287
+ try { await runtime.close(); } catch (error) { errors.push(error); }
288
+ if (errors.length === 0) try {
289
+ await this.database.changeCodexRun(homeId, runId, null, plugins.crypto.randomUUID());
290
+ this.managedCodexHomes.delete(homeId);
291
+ this.managedCodexAccountIds.delete(scopeId);
292
+ } catch (error) { errors.push(error); }
293
+ 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.');
295
+ })();
296
+ this.managedCodexStops.set(scopeId, stopping);
297
+ void stopping.finally(() => { if (this.managedCodexStops.get(scopeId) === stopping) this.managedCodexStops.delete(scopeId); }).catch(() => {});
298
+ return stopping;
299
+ }
300
+
125
301
  private async listen(path: string, router: plugins.typedrequest.TypedRouter): Promise<plugins.net.Server> {
126
302
  const directory = plugins.path.dirname(path);
127
303
  await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -201,7 +377,15 @@ export class AuthSwitchAuthorityDaemon {
201
377
  }
202
378
 
203
379
  public async close(): Promise<void> {
380
+ this.closing = true;
204
381
  const errors: unknown[] = [];
382
+ for (const scopeId of [...this.managedCodex.keys()]) {
383
+ try { await this.stopManagedCodex(scopeId); } catch (error) { errors.push(error); }
384
+ }
385
+ for (const stopping of [...this.managedCodexStops.values()]) {
386
+ try { await stopping; } catch (error) { errors.push(error); }
387
+ }
388
+ errors.push(...this.managedCodexFailures);
205
389
  try { await this.broker.close(); } catch (error) { errors.push(error); }
206
390
  const management = this.managementServer;
207
391
  const runtime = this.runtimeServer;
@@ -1,11 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import {
3
- AuthSwitchAuthorityAccountModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityEnrollmentModel,
3
+ AuthSwitchAuthorityAccountModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityCodexHomeModel, AuthSwitchAuthorityEnrollmentModel,
4
4
  AuthSwitchAuthorityEventModel, AuthSwitchAuthorityGrantModel, AuthSwitchAuthorityHandoffModel,
5
5
  AuthSwitchAuthorityMetaModel, AuthSwitchAuthorityMigrationLedgerModel, assertStoredAuthorityAccount,
6
6
  assertStoredAuthorityBinding, assertStoredAuthorityEnrollment, assertStoredAuthorityGrant,
7
7
  assertStoredAuthorityHandoff, assertStoredAuthorityMigrationLedger,
8
- type IStoredAuthorityAccount, type IStoredAuthorityBinding, type IStoredAuthorityEnrollment,
8
+ type IStoredAuthorityAccount, type IStoredAuthorityBinding, type IStoredAuthorityCodexHome, type IStoredAuthorityEnrollment,
9
9
  type IStoredAuthorityEvent, type IStoredAuthorityGrant, type IStoredAuthorityHandoff,
10
10
  type IStoredAuthorityMeta, type IStoredAuthorityMigrationLedger,
11
11
  } from './classes.authoritymodels.js';
@@ -46,17 +46,17 @@ export class AuthSwitchAuthorityDatabase {
46
46
  const database = new plugins.smartdata.SmartdataDb({ mongoDbUrl: connection.connectionUri, mongoDbName: 'authswitch' });
47
47
  this.database = database;
48
48
  for (const model of [AuthSwitchAuthorityMetaModel, AuthSwitchAuthorityAccountModel,
49
- AuthSwitchAuthorityGrantModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityEnrollmentModel,
49
+ AuthSwitchAuthorityGrantModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityCodexHomeModel, AuthSwitchAuthorityEnrollmentModel,
50
50
  AuthSwitchAuthorityHandoffModel, AuthSwitchAuthorityMigrationLedgerModel, AuthSwitchAuthorityEventModel]) {
51
51
  plugins.smartdata.setDefaultManagerForDoc({ db: database }, model);
52
52
  }
53
53
  await database.init();
54
54
  await Promise.all([AuthSwitchAuthorityMetaModel.init(), AuthSwitchAuthorityAccountModel.init(),
55
- AuthSwitchAuthorityGrantModel.init(), AuthSwitchAuthorityBindingModel.init(),
55
+ AuthSwitchAuthorityGrantModel.init(), AuthSwitchAuthorityBindingModel.init(), AuthSwitchAuthorityCodexHomeModel.init(),
56
56
  AuthSwitchAuthorityEnrollmentModel.init(), AuthSwitchAuthorityHandoffModel.init(),
57
57
  AuthSwitchAuthorityMigrationLedgerModel.init(), AuthSwitchAuthorityEventModel.init()]);
58
58
  await Promise.all([AuthSwitchAuthorityMetaModel.exact.prepare(), AuthSwitchAuthorityAccountModel.exact.prepare(),
59
- AuthSwitchAuthorityGrantModel.exact.prepare(), AuthSwitchAuthorityBindingModel.exact.prepare(),
59
+ AuthSwitchAuthorityGrantModel.exact.prepare(), AuthSwitchAuthorityBindingModel.exact.prepare(), AuthSwitchAuthorityCodexHomeModel.exact.prepare(),
60
60
  AuthSwitchAuthorityEnrollmentModel.exact.prepare(), AuthSwitchAuthorityHandoffModel.exact.prepare(),
61
61
  AuthSwitchAuthorityMigrationLedgerModel.exact.prepare(), AuthSwitchAuthorityEventModel.exact.prepare()]);
62
62
  const initial: IStoredAuthorityMeta = {
@@ -105,6 +105,77 @@ export class AuthSwitchAuthorityDatabase {
105
105
  return stored ? AuthSwitchAuthorityBindingModel.exact.toPersisted(stored) : null;
106
106
  }
107
107
 
108
+ public async readCodexHome(id: string): Promise<IStoredAuthorityCodexHome | null> {
109
+ this.db();
110
+ const stored = await AuthSwitchAuthorityCodexHomeModel.exact.findStoredOne({ id });
111
+ return stored ? AuthSwitchAuthorityCodexHomeModel.exact.toPersisted(stored) : null;
112
+ }
113
+
114
+ /** Home path identity is hashed by the caller; no vendor files or raw paths enter SmartData. */
115
+ public async reserveCodexHome(accountId: string, scopeId: string, homeId: string,
116
+ updateId: string): Promise<{ newlyRegistered: boolean }> {
117
+ try {
118
+ return await this.transaction(async session => {
119
+ const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
120
+ const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
121
+ const grantStored = account?.primaryGrantId
122
+ ? await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: account.primaryGrantId }, { session }) : null;
123
+ const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
124
+ if (!account || account.removed || account.providerId !== 'openai' || grant?.owner !== 'daemon'
125
+ || grant.purpose !== 'openai_managed' || grant.accountId !== accountId || grant.state === 'removed') {
126
+ throw new Error('Managed Codex requires a daemon-owned OpenAI account.');
127
+ }
128
+ const used = await AuthSwitchAuthorityCodexHomeModel.exact.findStoredOne({ id: homeId }, { session });
129
+ if (used) {
130
+ if (used.accountId !== accountId || used.scopeId !== scopeId) {
131
+ throw new Error('Managed Codex home already belongs to another account or scope.');
132
+ }
133
+ return { newlyRegistered: false };
134
+ }
135
+ const record: IStoredAuthorityCodexHome = { id: homeId, accountId, scopeId, activeRun: null,
136
+ createdAt: new Date().toISOString(), updateId };
137
+ const inserted = await AuthSwitchAuthorityCodexHomeModel.exact.insert(record, { session });
138
+ if (inserted.status !== 'inserted') throw new Error('Managed Codex home changed during admission.');
139
+ return { newlyRegistered: true };
140
+ });
141
+ } catch (error) {
142
+ if (error instanceof plugins.smartdata.SmartdataExactPersistenceError
143
+ && (error.code === 'ambiguous_write' || error.code === 'unique_conflict')) {
144
+ const assigned = await AuthSwitchAuthorityCodexHomeModel.exact.findStoredOne({ id: homeId });
145
+ if (assigned?.accountId === accountId && assigned.scopeId === scopeId
146
+ && assigned.updateId === updateId) return { newlyRegistered: true };
147
+ throw new Error('Managed Codex home registration changed concurrently; refresh before retrying.');
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ /** Pre-spawn claim is durable; the exact socket lets a later daemon detect a child born before PID readback. */
154
+ public async changeCodexRun(homeId: string, expectedRunId: string | null,
155
+ activeRun: IStoredAuthorityCodexHome['activeRun'], updateId: string): Promise<void> {
156
+ try {
157
+ await this.transaction(async session => {
158
+ const current = await AuthSwitchAuthorityCodexHomeModel.exact.findStoredOne({ id: homeId }, { session });
159
+ if (!current || current.activeRun?.id !== (expectedRunId ?? undefined)) {
160
+ throw new Error('Managed Codex home run changed concurrently.');
161
+ }
162
+ if (activeRun && expectedRunId !== null && activeRun.id !== expectedRunId) {
163
+ throw new Error('Managed Codex run identity cannot change.');
164
+ }
165
+ const draft = { ...AuthSwitchAuthorityCodexHomeModel.exact.toPersisted(current), activeRun, updateId };
166
+ const result = await AuthSwitchAuthorityCodexHomeModel.exact.transition({ current,
167
+ change: model => Object.assign(model, draft) }, { session });
168
+ if (result.status !== 'transitioned') throw new Error('Managed Codex home run changed concurrently.');
169
+ return true;
170
+ });
171
+ } catch (error) {
172
+ if (!(error instanceof plugins.smartdata.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
173
+ const current = await this.readCodexHome(homeId);
174
+ if (current?.updateId === updateId && JSON.stringify(current.activeRun) === JSON.stringify(activeRun)) return;
175
+ throw new Error('Managed Codex home run outcome is unknown.');
176
+ }
177
+ }
178
+
108
179
  /** One consistent authorization view. Callers must not combine separate binding and grant reads. */
109
180
  public async readBindingContext(id: string): Promise<{
110
181
  binding: IStoredAuthorityBinding | null;
@@ -557,6 +628,28 @@ export class AuthSwitchAuthorityDatabase {
557
628
  }
558
629
  }
559
630
 
631
+ /** Closing an owned runtime revokes only its exact capability/incarnation. */
632
+ public async revokeBinding(updateId: string, bindingId: string, capabilityHash: string): Promise<boolean> {
633
+ try {
634
+ return await this.transaction(async session => {
635
+ const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
636
+ if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
637
+ const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
638
+ if (!current || current.capabilityHash !== capabilityHash) return false;
639
+ const result = await AuthSwitchAuthorityBindingModel.exact.delete({ current }, { session });
640
+ if (result.status !== 'deleted') throw new Error('Runtime binding changed during revocation.');
641
+ await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
642
+ updateId, 'binding', current.accountId);
643
+ return true;
644
+ });
645
+ } catch (error) {
646
+ if (!(error instanceof plugins.smartdata.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
647
+ const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
648
+ if (event && !(await this.readBinding(bindingId))) return true;
649
+ throw new Error('Authswitch binding revocation outcome is unknown.');
650
+ }
651
+ }
652
+
560
653
  /** Enrollment metadata is sealed by the caller and cannot become detached from an account. */
561
654
  public async changeEnrollment(updateId: string, enrollmentId: string, accountId: string,
562
655
  change: (current: IStoredAuthorityEnrollment | null) => IStoredAuthorityEnrollment): Promise<IStoredAuthorityEnrollment> {
@@ -71,6 +71,16 @@ export interface IStoredAuthorityEnrollment {
71
71
  updateId: string;
72
72
  }
73
73
 
74
+ /** An immutable assignment of one private native Codex state home to one account and runtime scope. */
75
+ export interface IStoredAuthorityCodexHome {
76
+ id: string;
77
+ accountId: string;
78
+ scopeId: string;
79
+ activeRun: { id: string; socketPath: string; pid: number | null; startedAt: string | null } | null;
80
+ createdAt: string;
81
+ updateId: string;
82
+ }
83
+
74
84
  /** Journal for a native-home handoff. A prepared grant is never usable. */
75
85
  export interface IStoredAuthorityHandoff {
76
86
  id: string;
@@ -191,6 +201,23 @@ export const assertStoredAuthorityEnrollment: (value: unknown) => asserts value
191
201
  || !revision(value.revision) || !uuid(value.updateId)) throw new Error('Invalid authswitch enrollment record.');
192
202
  };
193
203
 
204
+ export const assertStoredAuthorityCodexHome: (value: unknown) => asserts value is IStoredAuthorityCodexHome = value => {
205
+ if (!object(value) || !exactKeys(value, ['id', 'accountId', 'scopeId', 'activeRun', 'createdAt', 'updateId'])
206
+ || !hash(value.id) || !hash(value.accountId) || !bounded(value.scopeId)
207
+ || !iso(value.createdAt) || !uuid(value.updateId)) {
208
+ throw new Error('Invalid managed Codex home assignment.');
209
+ }
210
+ if (value.activeRun !== null && (!object(value.activeRun)
211
+ || !exactKeys(value.activeRun, ['id', 'socketPath', 'pid', 'startedAt'])
212
+ || !uuid(value.activeRun.id) || !bounded(value.activeRun.socketPath, 1024)
213
+ || (value.activeRun.pid !== null && (typeof value.activeRun.pid !== 'number'
214
+ || !Number.isSafeInteger(value.activeRun.pid) || value.activeRun.pid <= 0))
215
+ || (value.activeRun.startedAt !== null && !iso(value.activeRun.startedAt))
216
+ || (value.activeRun.pid === null) !== (value.activeRun.startedAt === null))) {
217
+ throw new Error('Invalid managed Codex run assignment.');
218
+ }
219
+ };
220
+
194
221
  export const assertStoredAuthorityHandoff: (value: unknown) => asserts value is IStoredAuthorityHandoff = value => {
195
222
  if (!object(value) || !exactKeys(value, ['id', 'accountId', 'grantId', 'nativeHomeId', 'direction', 'phase',
196
223
  'operationId', 'sourceDigest', 'verifiedSourceDigest', 'backupDigest', 'revision', 'statusObservedAt', 'updateId'])
@@ -315,6 +342,19 @@ export class AuthSwitchAuthorityEnrollmentModel extends plugins.smartdata.SmartD
315
342
  @plugins.smartdata.svDb() public updateId!: string;
316
343
  }
317
344
 
345
+ @plugins.smartdata.compoundIndex({ name: 'authority_codex_home_account_id', key: { accountId: 1, id: 1 } })
346
+ @plugins.smartdata.managed({ collectionName: 'authswitch_authority_codex_homes' })
347
+ @plugins.smartdata.exactPersistence({ assertDocument: assertStoredAuthorityCodexHome })
348
+ export class AuthSwitchAuthorityCodexHomeModel extends plugins.smartdata.SmartDataDbDoc<AuthSwitchAuthorityCodexHomeModel, IStoredAuthorityCodexHome> {
349
+ declare static exact: plugins.smartdata.TExact<AuthSwitchAuthorityCodexHomeModel>;
350
+ @plugins.smartdata.unI() public id!: string;
351
+ @plugins.smartdata.svDb() public accountId!: string;
352
+ @plugins.smartdata.svDb() public scopeId!: string;
353
+ @plugins.smartdata.svDb() public activeRun!: IStoredAuthorityCodexHome['activeRun'];
354
+ @plugins.smartdata.svDb() public createdAt!: string;
355
+ @plugins.smartdata.svDb() public updateId!: string;
356
+ }
357
+
318
358
  @plugins.smartdata.compoundIndex({ name: 'authority_handoff_grant_id', key: { grantId: 1, id: 1 } })
319
359
  @plugins.smartdata.managed({ collectionName: 'authswitch_authority_handoffs' })
320
360
  @plugins.smartdata.exactPersistence({ assertDocument: assertStoredAuthorityHandoff })