@modelprofile.com/authswitch 6.3.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.
Files changed (43) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +142 -0
  3. package/dist_ts/authority-contract.js +3 -0
  4. package/dist_ts/authority-runtime-contract.d.ts +34 -0
  5. package/dist_ts/authority-runtime-contract.js +2 -0
  6. package/dist_ts/classes.authoritybroker.d.ts +76 -0
  7. package/dist_ts/classes.authoritybroker.js +570 -0
  8. package/dist_ts/classes.authorityclient.d.ts +47 -0
  9. package/dist_ts/classes.authorityclient.js +261 -0
  10. package/dist_ts/classes.authoritydaemon.d.ts +46 -0
  11. package/dist_ts/classes.authoritydaemon.js +492 -0
  12. package/dist_ts/classes.authoritydatabase.d.ts +103 -0
  13. package/dist_ts/classes.authoritydatabase.js +835 -0
  14. package/dist_ts/classes.authorityframing.d.ts +8 -0
  15. package/dist_ts/classes.authorityframing.js +22 -0
  16. package/dist_ts/classes.authoritymodels.d.ts +239 -0
  17. package/dist_ts/classes.authoritymodels.js +893 -0
  18. package/dist_ts/classes.authoritysecrets.d.ts +9 -0
  19. package/dist_ts/classes.authoritysecrets.js +30 -0
  20. package/dist_ts/classes.authorityservice.d.ts +28 -0
  21. package/dist_ts/classes.authorityservice.js +71 -0
  22. package/dist_ts/classes.codexmanaged.d.ts +80 -0
  23. package/dist_ts/classes.codexmanaged.js +343 -0
  24. package/dist_ts/index.d.ts +3 -0
  25. package/dist_ts/index.js +27 -1
  26. package/dist_ts/plugins.d.ts +11 -3
  27. package/dist_ts/plugins.js +12 -4
  28. package/package.json +25 -4
  29. package/readme.md +59 -0
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/authority-contract.ts +109 -0
  32. package/ts/authority-runtime-contract.ts +33 -0
  33. package/ts/classes.authoritybroker.ts +552 -0
  34. package/ts/classes.authorityclient.ts +252 -0
  35. package/ts/classes.authoritydaemon.ts +403 -0
  36. package/ts/classes.authoritydatabase.ts +818 -0
  37. package/ts/classes.authorityframing.ts +21 -0
  38. package/ts/classes.authoritymodels.ts +407 -0
  39. package/ts/classes.authoritysecrets.ts +27 -0
  40. package/ts/classes.authorityservice.ts +83 -0
  41. package/ts/classes.codexmanaged.ts +339 -0
  42. package/ts/index.ts +26 -0
  43. package/ts/plugins.ts +11 -3
@@ -0,0 +1,252 @@
1
+ import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
+ import * as plugins from './plugins.js';
3
+ import type {
4
+ IAuthSwitchAccountEvent, IAuthSwitchSnapshot, IReq_AuthSwitchBeginAdd,
5
+ IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation, IReq_AuthSwitchEvents,
6
+ IReq_AuthSwitchGetOperation, IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchRenameAccount,
7
+ IReq_AuthSwitchSnapshot,
8
+ } from './authority-contract.js';
9
+ import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
10
+ import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
11
+ maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
12
+
13
+ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promise<ITypedRequest> => {
14
+ return new Promise((resolve, reject) => {
15
+ if (signal?.aborted) { reject(signal.reason); return; }
16
+ const socket = plugins.net.createConnection(path);
17
+ let settled = false;
18
+ const frame = new AuthSwitchAuthorityFrameReader();
19
+ const finish = (error?: unknown, response?: ITypedRequest): void => {
20
+ if (settled) return;
21
+ settled = true;
22
+ signal?.removeEventListener('abort', abort);
23
+ socket.destroy();
24
+ if (error) reject(error);
25
+ else if (response) resolve(response);
26
+ else reject(new Error('Authswitch daemon returned no response.'));
27
+ };
28
+ const abort = () => finish(signal?.reason ?? new Error('Account request was cancelled.'));
29
+ signal?.addEventListener('abort', abort, { once: true });
30
+ socket.setTimeout(35_000, () => finish(new Error('Authswitch daemon request timed out.')));
31
+ socket.on('connect', () => {
32
+ const raw = JSON.stringify(payload);
33
+ if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { finish(new Error('Account request is too large.')); return; }
34
+ socket.write(raw + '\n');
35
+ });
36
+ socket.on('data', chunk => {
37
+ if (!Buffer.isBuffer(chunk)) { finish(new Error('Account response must be a byte frame.')); return; }
38
+ let input: string | null;
39
+ try { input = frame.add(chunk); }
40
+ catch (error) { finish(error); return; }
41
+ if (input === null) return;
42
+ try { finish(undefined, JSON.parse(input) as ITypedRequest); }
43
+ catch { finish(new Error('Account response could not be decoded.')); }
44
+ });
45
+ socket.on('error', () => finish(new Error('Authswitch daemon is unavailable.')));
46
+ socket.on('close', () => finish(new Error('Authswitch daemon connection closed before its response.')));
47
+ });
48
+ };
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
+
65
+ /** Node-only client for the per-user daemon. Each request has its own bounded Unix connection. */
66
+ export class AuthSwitchClient {
67
+ private readonly target: plugins.typedrequest.TypedTarget;
68
+ private readonly runtimeTarget: plugins.typedrequest.TypedTarget;
69
+
70
+ constructor(public readonly socketPath: string, public readonly runtimeSocketPath: string) {
71
+ this.target = new plugins.typedrequest.TypedTarget({
72
+ supportsAbortSignal: true,
73
+ postMethod: (payload, options) => post(this.socketPath, payload, options?.signal),
74
+ });
75
+ this.runtimeTarget = new plugins.typedrequest.TypedTarget({
76
+ supportsAbortSignal: true,
77
+ postMethod: (payload, options) => post(this.runtimeSocketPath, payload, options?.signal),
78
+ });
79
+ }
80
+
81
+ private request<T extends ITypedRequest>(method: T['method'], request: T['request'], timeoutMs = 35_000,
82
+ runtime = false, signal?: AbortSignal): Promise<T['response']> {
83
+ return new plugins.typedrequest.TypedRequest<T>(runtime ? this.runtimeTarget : this.target, method)
84
+ .fire(request, { timeoutMs, maxRetries: 0, abortSignal: signal });
85
+ }
86
+
87
+ public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}, signal?: AbortSignal): Promise<IAuthSwitchSnapshot> {
88
+ return (await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot', options, 35_000, false, signal)).snapshot;
89
+ }
90
+
91
+ /** Assemble a consistent view from bounded database pages; restart if a writer changes the revision. */
92
+ public async snapshotAll(options: { limit?: number; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
93
+ while (true) {
94
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error('Account snapshot cancelled.');
95
+ let accountAfter: string | undefined;
96
+ let bindingAfter: string | undefined;
97
+ let aggregate: IAuthSwitchSnapshot | undefined;
98
+ let changed = false;
99
+ while (true) {
100
+ const page = await this.snapshot({ accountAfter, bindingAfter, limit: options.limit }, options.signal);
101
+ if (!aggregate) aggregate = { ...page, accounts: [...page.accounts], bindings: [...page.bindings] };
102
+ else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
103
+ changed = true;
104
+ break;
105
+ } else {
106
+ aggregate.accounts.push(...page.accounts);
107
+ aggregate.bindings.push(...page.bindings);
108
+ }
109
+ accountAfter = page.nextAccountCursor ?? accountAfter ?? page.accounts.at(-1)?.id;
110
+ bindingAfter = page.nextBindingCursor ?? bindingAfter ?? page.bindings.at(-1)?.id;
111
+ if (!page.nextAccountCursor && !page.nextBindingCursor) {
112
+ return { ...aggregate, nextAccountCursor: null, nextBindingCursor: null };
113
+ }
114
+ }
115
+ if (!changed) throw new Error('Authority snapshot could not complete.');
116
+ }
117
+ }
118
+
119
+ public events(epoch: string, afterRevision: number, waitMs = 30_000, signal?: AbortSignal): Promise<IReq_AuthSwitchEvents['response']> {
120
+ return this.request<IReq_AuthSwitchEvents>('authswitch.authority.events', { epoch, afterRevision, waitMs }, waitMs + 5_000, false, signal);
121
+ }
122
+
123
+ /** Snapshot callback runs after every known change; onStatus invalidates it on disconnect, resync or abort. */
124
+ public async subscribe(onSnapshot: (snapshot: IAuthSwitchSnapshot) => void | 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
+ }
131
+ const retry = () => new Promise<void>(resolve => {
132
+ const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, 250);
133
+ const aborted = () => { clearTimeout(timer); resolve(); };
134
+ signal.addEventListener('abort', aborted, { once: true });
135
+ });
136
+ let snapshot: IAuthSwitchSnapshot | undefined;
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); }
171
+ catch {
172
+ if (signal.aborted) break;
173
+ await invalidate('disconnect');
174
+ await retry();
175
+ continue;
176
+ }
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
+ }
206
+ }
207
+ } finally {
208
+ available = false;
209
+ await status('closed', signal.aborted ? 'abort' : 'consumer_error');
210
+ }
211
+ }
212
+
213
+ public async beginAddOpenAi(): Promise<IReq_AuthSwitchBeginAdd['response']['operation']> {
214
+ return (await this.request<IReq_AuthSwitchBeginAdd>('authswitch.authority.add', { providerId: 'openai', flow: 'device' })).operation;
215
+ }
216
+ public async beginReauthOpenAi(accountId: string): Promise<IReq_AuthSwitchBeginReauth['response']['operation']> {
217
+ return (await this.request<IReq_AuthSwitchBeginReauth>('authswitch.authority.reauth', { accountId, flow: 'device' })).operation;
218
+ }
219
+ public async getOperation(operationId: string): Promise<IReq_AuthSwitchGetOperation['response']['operation']> {
220
+ return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation', { operationId })).operation;
221
+ }
222
+ public async cancelOperation(operationId: string): Promise<IReq_AuthSwitchCancelOperation['response']['operation']> {
223
+ return (await this.request<IReq_AuthSwitchCancelOperation>('authswitch.authority.cancel', { operationId })).operation;
224
+ }
225
+ public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IReq_AuthSwitchRenameAccount['response']['account']> {
226
+ return (await this.request<IReq_AuthSwitchRenameAccount>('authswitch.authority.rename', { accountId, expectedRevision, label })).account;
227
+ }
228
+ public async removeAccount(accountId: string, expectedRevision: number): Promise<IReq_AuthSwitchRemoveAccount['response']['account']> {
229
+ return (await this.request<IReq_AuthSwitchRemoveAccount>('authswitch.authority.remove', { accountId, expectedRevision })).account;
230
+ }
231
+ public bindAccount(input: IReq_AuthSwitchBindAccount['request']): Promise<IReq_AuthSwitchBindAccount['response']> {
232
+ return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input);
233
+ }
234
+ public resolveAccess(input: IReq_AuthSwitchResolveAccess['request']): Promise<IReq_AuthSwitchResolveAccess['response']> {
235
+ return this.request<IReq_AuthSwitchResolveAccess>('authswitch.authority.resolveAccess', input, 35_000, true);
236
+ }
237
+ }
238
+
239
+ /** Container-facing client. It can only resolve a previously issued binding capability. */
240
+ export class AuthSwitchRuntimeClient {
241
+ private readonly target: plugins.typedrequest.TypedTarget;
242
+ constructor(runtimeSocketPath: string) {
243
+ this.target = new plugins.typedrequest.TypedTarget({
244
+ supportsAbortSignal: true,
245
+ postMethod: (payload, options) => post(runtimeSocketPath, payload, options?.signal),
246
+ });
247
+ }
248
+ public resolveAccess(input: IReq_AuthSwitchResolveAccess['request']): Promise<IReq_AuthSwitchResolveAccess['response']> {
249
+ return new plugins.typedrequest.TypedRequest<IReq_AuthSwitchResolveAccess>(this.target,
250
+ 'authswitch.authority.resolveAccess').fire(input, { timeoutMs: 35_000, maxRetries: 0 });
251
+ }
252
+ }
@@ -0,0 +1,403 @@
1
+ import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
2
+ import * as plugins from './plugins.js';
3
+ import type {
4
+ IReq_AuthSwitchBeginAdd, IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
5
+ IReq_AuthSwitchEvents, IReq_AuthSwitchGetOperation, IReq_AuthSwitchRemoveAccount,
6
+ IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSnapshot,
7
+ } 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';
10
+ import { AuthSwitchAuthorityDatabase, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
11
+ import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
12
+ maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
13
+ import { AuthSwitchManagedCodex, findManagedCodexProcess, type IAuthSwitchManagedCodexOptions } from './classes.codexmanaged.js';
14
+
15
+ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityDatabaseOptions {
16
+ authoritySocketPath: string;
17
+ runtimeSocketPath: string;
18
+ broker?: IAuthSwitchAuthorityBrokerOptions;
19
+ }
20
+
21
+ const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
22
+ value !== null && typeof value === 'object' && !Array.isArray(value)
23
+ && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
24
+ const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
25
+
26
+ /** TypedRequest handlers over a private one-request-per-connection Unix socket. */
27
+ export class AuthSwitchAuthorityDaemon {
28
+ public readonly database: AuthSwitchAuthorityDatabase;
29
+ public readonly broker: AuthSwitchAuthorityBroker;
30
+ private readonly managementRouter = new plugins.typedrequest.TypedRouter();
31
+ private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
32
+ private managementServer?: plugins.net.Server;
33
+ private runtimeServer?: plugins.net.Server;
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;
43
+
44
+ constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
45
+ this.database = new AuthSwitchAuthorityDatabase(options);
46
+ this.broker = new AuthSwitchAuthorityBroker(this.database, options.broker);
47
+ this.register();
48
+ }
49
+
50
+ private register(): void {
51
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchSnapshot>(
52
+ 'authswitch.authority.snapshot', async request => {
53
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
54
+ const keys = Object.keys(request);
55
+ if (keys.some(key => !['accountAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
56
+ return { snapshot: await this.broker.snapshot(request) };
57
+ }));
58
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchEvents>(
59
+ 'authswitch.authority.events', async (request, tools) => {
60
+ if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
61
+ return this.broker.events(request.epoch, request.afterRevision, request.waitMs, tools?.abortSignal);
62
+ }));
63
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginAdd>(
64
+ '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() };
67
+ }));
68
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBeginReauth>(
69
+ '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; }
79
+ }));
80
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchGetOperation>(
81
+ 'authswitch.authority.operation', async request => {
82
+ if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
83
+ return { operation: this.broker.getOperation(request.operationId) };
84
+ }));
85
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchCancelOperation>(
86
+ 'authswitch.authority.cancel', async request => {
87
+ if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
88
+ return { operation: await this.broker.cancelOperation(request.operationId) };
89
+ }));
90
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRenameAccount>(
91
+ 'authswitch.authority.rename', async request => {
92
+ if (!hasKeys(request, ['accountId', 'expectedRevision', 'label']) || typeof request.accountId !== 'string'
93
+ || typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
94
+ return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
95
+ }));
96
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchRemoveAccount>(
97
+ 'authswitch.authority.remove', async request => {
98
+ if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
99
+ || typeof request.expectedRevision !== 'number') invalid();
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); }
106
+ }));
107
+ this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchBindAccount>(
108
+ 'authswitch.authority.bind', async request => {
109
+ if (!hasKeys(request, ['accountId', 'runtime', 'scopeId', 'incarnationId'])
110
+ || typeof request.accountId !== 'string' || typeof request.scopeId !== 'string'
111
+ || typeof request.incarnationId !== 'string') invalid();
112
+ return this.broker.bindAccount(request);
113
+ }));
114
+ this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchResolveAccess>(
115
+ 'authswitch.authority.resolveAccess', async request => {
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))
120
+ || typeof request.bindingId !== 'string' || typeof request.capability !== 'string'
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);
125
+ }));
126
+ }
127
+
128
+ public async start(): Promise<void> {
129
+ if (this.managementServer || this.runtimeServer) throw new Error('Authswitch authority daemon is already started.');
130
+ await this.database.start();
131
+ try {
132
+ await this.broker.start();
133
+ const managementDirectory = plugins.path.dirname(this.options.authoritySocketPath);
134
+ const runtimeDirectory = plugins.path.dirname(this.options.runtimeSocketPath);
135
+ const databaseDirectory = plugins.path.dirname(this.options.socketPath);
136
+ if (managementDirectory === runtimeDirectory || databaseDirectory === runtimeDirectory
137
+ || managementDirectory.startsWith(runtimeDirectory + plugins.path.sep)
138
+ || databaseDirectory.startsWith(runtimeDirectory + plugins.path.sep)
139
+ || runtimeDirectory.startsWith(managementDirectory + plugins.path.sep)
140
+ || runtimeDirectory.startsWith(databaseDirectory + plugins.path.sep)) {
141
+ throw new Error('Runtime socket directory must contain no management or database endpoint.');
142
+ }
143
+ this.managementServer = await this.listen(this.options.authoritySocketPath, this.managementRouter);
144
+ this.runtimeServer = await this.listen(this.options.runtimeSocketPath, this.runtimeRouter);
145
+ } catch (error) {
146
+ try { await this.close(); } catch { /* Preserve startup failure. */ }
147
+ throw error;
148
+ }
149
+ }
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
+
301
+ private async listen(path: string, router: plugins.typedrequest.TypedRouter): Promise<plugins.net.Server> {
302
+ const directory = plugins.path.dirname(path);
303
+ await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
304
+ const stat = await plugins.fs.promises.lstat(directory);
305
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()) {
306
+ throw new Error('Authswitch authority socket directory is not private.');
307
+ }
308
+ await plugins.fs.promises.chmod(directory, 0o700);
309
+ await this.clearStaleSocket(path);
310
+ const server = plugins.net.createServer(socket => this.accept(socket, router));
311
+ try {
312
+ await new Promise<void>((resolve, reject) => {
313
+ server.once('error', reject);
314
+ server.listen(path, () => { server.off('error', reject); resolve(); });
315
+ });
316
+ await plugins.fs.promises.chmod(path, 0o600);
317
+ return server;
318
+ } catch (error) {
319
+ if (server.listening) await new Promise<void>(resolve => server.close(() => resolve()));
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ /** A killed daemon leaves a pathname behind; only remove an unchanged, owned, refused Unix socket. */
325
+ private async clearStaleSocket(path: string): Promise<void> {
326
+ let before: plugins.fs.Stats;
327
+ try { before = await plugins.fs.promises.lstat(path); }
328
+ catch (error) {
329
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
330
+ throw error;
331
+ }
332
+ if (!before.isSocket() || before.uid !== process.getuid?.()) throw new Error('Authswitch socket path is unsafe.');
333
+ const refused = await new Promise<boolean>((resolve, reject) => {
334
+ const probe = plugins.net.createConnection(path);
335
+ probe.setTimeout(1_000, () => { probe.destroy(); reject(new Error('Authswitch socket probe timed out.')); });
336
+ probe.once('connect', () => { probe.destroy(); resolve(false); });
337
+ probe.once('error', error => {
338
+ probe.destroy();
339
+ if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') resolve(true);
340
+ else reject(error);
341
+ });
342
+ });
343
+ if (!refused) throw new Error('Authswitch authority socket is already active.');
344
+ const after = await plugins.fs.promises.lstat(path);
345
+ if (!after.isSocket() || after.uid !== before.uid || after.dev !== before.dev || after.ino !== before.ino) {
346
+ throw new Error('Authswitch socket path changed during startup.');
347
+ }
348
+ await plugins.fs.promises.unlink(path);
349
+ }
350
+
351
+ private accept(socket: plugins.net.Socket, router: plugins.typedrequest.TypedRouter): void {
352
+ this.sockets.add(socket);
353
+ const abort = new AbortController();
354
+ socket.on('close', () => { this.sockets.delete(socket); abort.abort(); });
355
+ const frame = new AuthSwitchAuthorityFrameReader();
356
+ let handled = false;
357
+ socket.setTimeout(35_000, () => socket.destroy());
358
+ socket.on('data', chunk => {
359
+ if (handled) { socket.destroy(); return; }
360
+ if (!Buffer.isBuffer(chunk)) { socket.destroy(); return; }
361
+ let input: string | null;
362
+ try { input = frame.add(chunk); }
363
+ catch { socket.destroy(); return; }
364
+ if (input === null) return;
365
+ handled = true;
366
+ let request: ITypedRequest;
367
+ try { request = JSON.parse(input) as ITypedRequest; }
368
+ catch { socket.destroy(); return; }
369
+ void router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
370
+ trustedAbortSignal: abort.signal })
371
+ .then(response => {
372
+ const raw = JSON.stringify(response);
373
+ if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { socket.destroy(); return; }
374
+ socket.end(raw + '\n');
375
+ }).catch(() => socket.destroy());
376
+ });
377
+ }
378
+
379
+ public async close(): Promise<void> {
380
+ this.closing = true;
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);
389
+ try { await this.broker.close(); } catch (error) { errors.push(error); }
390
+ const management = this.managementServer;
391
+ const runtime = this.runtimeServer;
392
+ this.managementServer = undefined;
393
+ this.runtimeServer = undefined;
394
+ for (const socket of this.sockets) socket.destroy();
395
+ for (const server of [management, runtime]) {
396
+ if (server) try {
397
+ await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
398
+ } catch (error) { errors.push(error); }
399
+ }
400
+ try { await this.database.stop(); } catch (error) { errors.push(error); }
401
+ if (errors.length) throw new AggregateError(errors, 'Authswitch authority shutdown was incomplete.');
402
+ }
403
+ }