@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-runtime-contract.d.ts +2 -0
- package/dist_ts/classes.authoritybroker.d.ts +4 -1
- package/dist_ts/classes.authoritybroker.js +32 -10
- package/dist_ts/classes.authorityclient.d.ts +15 -2
- package/dist_ts/classes.authorityclient.js +86 -25
- package/dist_ts/classes.authoritydaemon.d.ts +20 -0
- package/dist_ts/classes.authoritydaemon.js +249 -6
- package/dist_ts/classes.authoritydatabase.d.ts +10 -1
- package/dist_ts/classes.authoritydatabase.js +104 -5
- package/dist_ts/classes.authoritymodels.d.ts +24 -0
- package/dist_ts/classes.authoritymodels.js +76 -1
- package/dist_ts/classes.codexmanaged.d.ts +80 -0
- package/dist_ts/classes.codexmanaged.js +343 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +9 -2
- package/readme.md +27 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-runtime-contract.ts +7 -1
- package/ts/classes.authoritybroker.ts +31 -9
- package/ts/classes.authorityclient.ts +89 -20
- package/ts/classes.authoritydaemon.ts +189 -5
- package/ts/classes.authoritydatabase.ts +98 -5
- package/ts/classes.authoritymodels.ts +40 -0
- package/ts/classes.codexmanaged.ts +339 -0
- package/ts/index.ts +1 -0
- package/ts/plugins.ts +2 -1
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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'
|
|
98
|
-
|
|
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 })
|