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