@modelprofile.com/authswitch 9.1.0 → 10.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 +2 -2
- package/dist_ts/authority-contract.d.ts +56 -6
- package/dist_ts/authority-contract.js +1 -1
- package/dist_ts/authority-runtime-contract.d.ts +29 -0
- package/dist_ts/classes.authoritybroker.d.ts +36 -4
- package/dist_ts/classes.authoritybroker.js +71 -12
- package/dist_ts/classes.authoritycli.d.ts +4 -1
- package/dist_ts/classes.authoritycli.js +42 -7
- package/dist_ts/classes.authorityclient.d.ts +15 -2
- package/dist_ts/classes.authorityclient.js +31 -8
- package/dist_ts/classes.authoritydaemon.d.ts +17 -0
- package/dist_ts/classes.authoritydaemon.js +50 -14
- package/dist_ts/classes.authoritydatabase.d.ts +14 -3
- package/dist_ts/classes.authoritydatabase.js +25 -10
- package/dist_ts/classes.authorityimport.d.ts +4 -4
- package/dist_ts/classes.authorityimport.js +8 -7
- package/dist_ts/classes.authoritymodels.d.ts +11 -3
- package/dist_ts/classes.authoritymodels.js +43 -13
- package/dist_ts/classes.authoritypreuse.d.ts +8 -2
- package/dist_ts/classes.authoritypreuse.js +27 -17
- package/dist_ts/classes.authorityusage.d.ts +4 -3
- package/dist_ts/classes.authorityusage.js +3 -5
- package/dist_ts/classes.cli.js +3 -5
- package/dist_ts/classes.codexpreuse.d.ts +14 -3
- package/dist_ts/classes.codexpreuse.js +127 -44
- package/dist_ts/classes.codexstatus.js +44 -95
- package/dist_ts/classes.login.d.ts +20 -10
- package/dist_ts/classes.login.js +14 -13
- package/dist_ts/classes.openaiprovider.d.ts +53 -0
- package/dist_ts/classes.openaiprovider.js +340 -0
- package/dist_ts/classes.opencodeharness.js +3 -2
- package/dist_ts/classes.service.js +3 -5
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/interfaces.openai.d.ts +118 -0
- package/dist_ts/interfaces.openai.js +3 -0
- package/dist_ts/openaiauth.d.ts +76 -0
- package/dist_ts/openaiauth.js +371 -0
- package/dist_ts/openaiusage.d.ts +59 -0
- package/dist_ts/openaiusage.js +222 -0
- package/dist_ts/plugins.d.ts +1 -3
- package/dist_ts/plugins.js +2 -4
- package/dist_ts/preuse.d.ts +50 -0
- package/dist_ts/preuse.js +63 -4
- package/dist_ts/ts_migration/legacysources/shared.js +4 -3
- package/openai-codex-license.txt +201 -0
- package/package.json +6 -4
- package/readme.md +121 -26
- package/third-party-notices.md +29 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +60 -8
- package/ts/authority-runtime-contract.ts +30 -0
- package/ts/classes.authoritybroker.ts +80 -17
- package/ts/classes.authoritycli.ts +48 -6
- package/ts/classes.authorityclient.ts +32 -9
- package/ts/classes.authoritydaemon.ts +54 -14
- package/ts/classes.authoritydatabase.ts +28 -11
- package/ts/classes.authorityimport.ts +12 -12
- package/ts/classes.authoritymodels.ts +42 -14
- package/ts/classes.authoritypreuse.ts +30 -17
- package/ts/classes.authorityusage.ts +8 -9
- package/ts/classes.cli.ts +3 -3
- package/ts/classes.codexpreuse.ts +130 -40
- package/ts/classes.codexstatus.ts +40 -73
- package/ts/classes.login.ts +34 -21
- package/ts/classes.openaiprovider.ts +365 -0
- package/ts/classes.opencodeharness.ts +3 -2
- package/ts/classes.service.ts +3 -4
- package/ts/index.ts +2 -0
- package/ts/interfaces.openai.ts +142 -0
- package/ts/openaiauth.ts +422 -0
- package/ts/openaiusage.ts +247 -0
- package/ts/plugins.ts +1 -3
- package/ts/preuse.ts +88 -3
- package/ts/ts_migration/legacysources/shared.ts +4 -3
|
@@ -25,6 +25,36 @@ export interface IReq_AuthSwitchBindAccount extends ITypedRequest {
|
|
|
25
25
|
response: { binding: IAuthSwitchBinding; capability: string };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Backend-only release of a binding by the identity its holder bound it under, served on the MANAGEMENT
|
|
30
|
+
* socket beside `bind`, of which it is the inverse.
|
|
31
|
+
*
|
|
32
|
+
* It is how a holder releases a binding it no longer holds the capability for -- one an earlier run of it
|
|
33
|
+
* left behind -- and it needs no ready login: an account whose sign-in has ended can have its leftover
|
|
34
|
+
* bindings released, and then be removed, without signing in again first. It is authorized as `bind` is: a
|
|
35
|
+
* caller trusted with the management socket names a runtime and scope, the namespace a holder binds under
|
|
36
|
+
* and owns. It releases only the binding at exactly that runtime and scope, and only while it is still the
|
|
37
|
+
* incarnation and revision the holder read from the snapshot or `getBinding`; any other binding -- another
|
|
38
|
+
* scope, another runtime, or a successor bound meanwhile -- is left alone and the answer is `inactive`.
|
|
39
|
+
* A binding's revision names one bind and never repeats for its runtime and scope, so a successor is told
|
|
40
|
+
* apart even when it was bound under the same incarnation after the one read was released.
|
|
41
|
+
* The daemon's own managed Codex bindings are never named here. Like the runtime release, it waits for
|
|
42
|
+
* the access resolutions already admitted for that binding.
|
|
43
|
+
*/
|
|
44
|
+
export interface IReq_AuthSwitchUnbind extends ITypedRequest {
|
|
45
|
+
method: 'authswitch.authority.unbind';
|
|
46
|
+
request: {
|
|
47
|
+
runtime: Exclude<IAuthSwitchBinding['runtime'], 'codex'>;
|
|
48
|
+
scopeId: string;
|
|
49
|
+
incarnationId: string;
|
|
50
|
+
/** The binding's `revision` as the holder read it. */
|
|
51
|
+
expectedRevision: number;
|
|
52
|
+
};
|
|
53
|
+
response: {
|
|
54
|
+
state: 'released' | 'inactive';
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
/** Backend-only access operation. Refresh grants never cross this interface. */
|
|
29
59
|
export interface IReq_AuthSwitchResolveAccess extends ITypedRequest {
|
|
30
60
|
method: 'authswitch.authority.resolveAccess';
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
|
|
3
|
+
import { parseOpenAiTokenClaims } from './openaiauth.js';
|
|
4
|
+
import type { IOpenAiLoginHandle, IOpenAiOAuthCredential } from './interfaces.openai.js';
|
|
2
5
|
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
|
|
3
6
|
IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
|
|
4
|
-
TAuthSwitchLoginOwnerTool } from './authority-contract.js';
|
|
7
|
+
TAuthSwitchLoginOwnerTool, TAuthSwitchRevocation } from './authority-contract.js';
|
|
5
8
|
import { AuthSwitchRefusal, isAuthSwitchAccountLabel } from './authority-contract.js';
|
|
6
9
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
7
10
|
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityClaudeHome, IStoredAuthorityGrant,
|
|
@@ -9,9 +12,9 @@ import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthority
|
|
|
9
12
|
import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
|
|
10
13
|
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
11
14
|
|
|
12
|
-
type TOpenAiCredential =
|
|
13
|
-
type TOpenAiProvider = Pick<
|
|
14
|
-
'beginLogin' | 'refreshCredential' | 'inspectCredential' | 'getAccountRateLimits' | 'dispose'>;
|
|
15
|
+
type TOpenAiCredential = IOpenAiOAuthCredential;
|
|
16
|
+
type TOpenAiProvider = Pick<AuthSwitchOpenAiProvider,
|
|
17
|
+
'beginLogin' | 'refreshCredential' | 'revokeCredential' | 'inspectCredential' | 'getAccountRateLimits' | 'dispose'>;
|
|
15
18
|
|
|
16
19
|
export interface IAuthSwitchAuthorityBrokerOptions {
|
|
17
20
|
provider?: TOpenAiProvider;
|
|
@@ -46,9 +49,15 @@ const safeScope = (value: unknown, maximum: number): value is string => typeof v
|
|
|
46
49
|
&& value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
|
|
47
50
|
const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
48
51
|
const tokenExpiry = (credential: TOpenAiCredential): string | null => {
|
|
49
|
-
try { return
|
|
52
|
+
try { return parseOpenAiTokenClaims(credential.accessToken).expiresAt ?? null; }
|
|
50
53
|
catch { return null; }
|
|
51
54
|
};
|
|
55
|
+
/**
|
|
56
|
+
* How long a removal's revocations may take in all. The daemon answers a removal once they settle, so the budget
|
|
57
|
+
* keeps a revoking removal inside the 35 seconds `AuthSwitchClient` allows any request, leaving the rest to the
|
|
58
|
+
* drain and the removal itself; a sign-in OpenAI has not confirmed revoking by then reports `failed`.
|
|
59
|
+
*/
|
|
60
|
+
const revocationBudgetMs = 10_000;
|
|
52
61
|
const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
|
|
53
62
|
|
|
54
63
|
/**
|
|
@@ -144,7 +153,7 @@ const publicClaudeAssignment = (home: IStoredAuthorityClaudeHome): IAuthSwitchNa
|
|
|
144
153
|
|
|
145
154
|
interface IManagedOperation {
|
|
146
155
|
id: string;
|
|
147
|
-
handle:
|
|
156
|
+
handle: IOpenAiLoginHandle;
|
|
148
157
|
task: Promise<void>;
|
|
149
158
|
}
|
|
150
159
|
|
|
@@ -172,7 +181,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
172
181
|
private closing?: Promise<void>;
|
|
173
182
|
|
|
174
183
|
constructor(private readonly database: AuthSwitchAuthorityDatabase, options: IAuthSwitchAuthorityBrokerOptions = {}) {
|
|
175
|
-
this.provider = options.provider ?? new
|
|
184
|
+
this.provider = options.provider ?? new AuthSwitchOpenAiProvider();
|
|
176
185
|
this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
|
|
177
186
|
this.now = options.now ?? Date.now;
|
|
178
187
|
this.proactive = options.proactive ?? true;
|
|
@@ -344,7 +353,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
344
353
|
}
|
|
345
354
|
|
|
346
355
|
private identity(credential: TOpenAiCredential): { id: string; subject: string; workspaceId: string; email: string | null; plan: string | null } {
|
|
347
|
-
const token =
|
|
356
|
+
const token = parseOpenAiTokenClaims(credential.idToken);
|
|
348
357
|
const subject = token.chatgptUserId;
|
|
349
358
|
const workspaceId = token.chatgptAccountId;
|
|
350
359
|
if (!subject || !workspaceId) throw new Error('OpenAI login omitted a stable user or account identity.');
|
|
@@ -453,7 +462,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
453
462
|
if (terminal.state === 'cancelled') return publicOperation(terminal);
|
|
454
463
|
throw error;
|
|
455
464
|
}
|
|
456
|
-
let handle:
|
|
465
|
+
let handle: IOpenAiLoginHandle;
|
|
457
466
|
try {
|
|
458
467
|
handle = await this.provider.beginLogin({ flow: 'device' });
|
|
459
468
|
} catch {
|
|
@@ -660,13 +669,43 @@ export class AuthSwitchAuthorityBroker {
|
|
|
660
669
|
return publicAccount(updated.account);
|
|
661
670
|
}
|
|
662
671
|
|
|
663
|
-
|
|
672
|
+
/**
|
|
673
|
+
* Removes an account. Only `revoke: true` reaches the network: it revokes the ChatGPT sign-in the removal
|
|
674
|
+
* discarded, after the removal committed.
|
|
675
|
+
*/
|
|
676
|
+
public async removeAccount(accountId: string, expectedRevision: number,
|
|
677
|
+
options: { revoke: boolean }): Promise<{ account: IAuthSwitchAccount; revoked: TAuthSwitchRevocation }> {
|
|
664
678
|
if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
|
|
665
679
|
const updateId = plugins.crypto.randomUUID();
|
|
666
|
-
const account = await this.database.removeAccountAndGrants(updateId, accountId,
|
|
680
|
+
const { account, retired } = await this.database.removeAccountAndGrants(updateId, accountId,
|
|
667
681
|
expectedRevision, new Date(this.now()).toISOString());
|
|
668
682
|
this.publish();
|
|
669
|
-
|
|
683
|
+
const revoked = options.revoke ? await this.revokeRetired(account, retired) : 'not_requested';
|
|
684
|
+
return { account: publicAccount(account), revoked };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Revokes the ChatGPT sign-ins a removal discarded, as Codex revokes its own when it logs out, so that sign-in
|
|
689
|
+
* stops working at the service too. Like Codex's, it is best effort: the account is already removed, and the
|
|
690
|
+
* log says when the service did not confirm the revocation. All of them share `revocationBudgetMs`: each is
|
|
691
|
+
* given what remains of it, and one the budget no longer reaches is not sent and counts as not confirmed.
|
|
692
|
+
*/
|
|
693
|
+
private async revokeRetired(account: IStoredAuthorityAccount,
|
|
694
|
+
retired: IStoredAuthorityGrant[]): Promise<Exclude<TAuthSwitchRevocation, 'not_requested'>> {
|
|
695
|
+
if (retired.length === 0) return 'not_applicable';
|
|
696
|
+
const deadline = this.now() + revocationBudgetMs;
|
|
697
|
+
let revoked: 'yes' | 'failed' = 'yes';
|
|
698
|
+
for (const grant of retired) {
|
|
699
|
+
const remainingMs = Math.floor(deadline - this.now());
|
|
700
|
+
const confirmed = remainingMs > 0 && await this.unsealCredential(account, grant)
|
|
701
|
+
.then(credential => this.provider.revokeCredential(credential, { timeoutMs: remainingMs }))
|
|
702
|
+
.then(() => true, () => false);
|
|
703
|
+
if (!confirmed) {
|
|
704
|
+
revoked = 'failed';
|
|
705
|
+
process.stderr.write('Authswitch removed an account, but OpenAI did not confirm revoking its sign-in.\n');
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return revoked;
|
|
670
709
|
}
|
|
671
710
|
|
|
672
711
|
public async bindAccount(input: { accountId: string; loginId: string; purpose: 'openai_managed';
|
|
@@ -678,7 +717,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
678
717
|
const capability = plugins.crypto.randomBytes(32).toString('base64url');
|
|
679
718
|
const capabilityHash = idHash(capability);
|
|
680
719
|
const updateId = plugins.crypto.randomUUID();
|
|
681
|
-
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (
|
|
720
|
+
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (_existing, account, grant, revision) => {
|
|
682
721
|
// A ChatGPT login backs every runtime, Claude Code included; a Claude account reaches Claude Code
|
|
683
722
|
// through its native switch instead, so it never binds (its provider is not OpenAI).
|
|
684
723
|
if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
|
|
@@ -687,7 +726,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
687
726
|
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
688
727
|
}
|
|
689
728
|
return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
|
|
690
|
-
incarnationId: input.incarnationId, revision
|
|
729
|
+
incarnationId: input.incarnationId, revision,
|
|
691
730
|
grantAuthorizationGeneration: grant.authorizationGeneration, capabilityHash, updateId };
|
|
692
731
|
});
|
|
693
732
|
this.publish();
|
|
@@ -709,13 +748,37 @@ export class AuthSwitchAuthorityBroker {
|
|
|
709
748
|
}
|
|
710
749
|
|
|
711
750
|
public async releaseExternalBinding(bindingId: string, capability: string): Promise<'released' | 'inactive'> {
|
|
712
|
-
if (
|
|
751
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
|
|
752
|
+
return this.releaseExternalCapability(bindingId, authSwitchBindingCapabilityHash(capability));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Releases a caller-held binding while it still carries exactly this capability, named by its hash. */
|
|
756
|
+
public async releaseExternalCapability(bindingId: string, capabilityHash: string): Promise<'released' | 'inactive'> {
|
|
757
|
+
if (!isId(bindingId) || !isId(capabilityHash)) throw new Error('Invalid runtime binding release.');
|
|
713
758
|
const released = await this.database.releaseExternalBinding(plugins.crypto.randomUUID(), bindingId,
|
|
714
|
-
|
|
759
|
+
capabilityHash);
|
|
715
760
|
if (released) this.publish();
|
|
716
761
|
return released ? 'released' : 'inactive';
|
|
717
762
|
}
|
|
718
763
|
|
|
764
|
+
/**
|
|
765
|
+
* The caller-held binding a holder names by the identity it bound under, while it is still exactly the
|
|
766
|
+
* incarnation and revision the holder read, or `null`. Its capability hash is what a release then fences
|
|
767
|
+
* and compares, so a successor bound after this read is never released in its place.
|
|
768
|
+
*/
|
|
769
|
+
public async findHeldBinding(input: { runtime: Exclude<IAuthSwitchBinding['runtime'], 'codex'>; scopeId: string;
|
|
770
|
+
incarnationId: string; expectedRevision: number }): Promise<{ bindingId: string; capabilityHash: string } | null> {
|
|
771
|
+
if (!['flex', 'opencode', 'claude'].includes(input.runtime) || !safeScope(input.scopeId, 512)
|
|
772
|
+
|| !safeScope(input.incarnationId, 256) || !validRevision(input.expectedRevision)) {
|
|
773
|
+
throw new Error('Invalid runtime binding release.');
|
|
774
|
+
}
|
|
775
|
+
const bindingId = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
|
|
776
|
+
const binding = await this.database.readBinding(bindingId);
|
|
777
|
+
if (!binding || binding.runtime !== input.runtime || binding.scopeId !== input.scopeId
|
|
778
|
+
|| binding.incarnationId !== input.incarnationId || binding.revision !== input.expectedRevision) return null;
|
|
779
|
+
return { bindingId, capabilityHash: binding.capabilityHash };
|
|
780
|
+
}
|
|
781
|
+
|
|
719
782
|
public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
|
|
720
783
|
rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
721
784
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
|
|
@@ -811,7 +874,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
811
874
|
}
|
|
812
875
|
if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
813
876
|
const credential = await this.unsealCredential(account, grant);
|
|
814
|
-
const info =
|
|
877
|
+
const info = parseOpenAiTokenClaims(credential.accessToken);
|
|
815
878
|
if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
|
|
816
879
|
throw new Error('Account access identity changed.');
|
|
817
880
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { IAuthSwitchDoctorPage, IAuthSwitchOperation, IAuthSwitchPreuseOperation,
|
|
2
|
-
IAuthSwitchSnapshot, IReq_AuthSwitchDoctor,
|
|
2
|
+
IAuthSwitchSnapshot, IReq_AuthSwitchDoctor, IReq_AuthSwitchRemoveAccount,
|
|
3
|
+
IReq_AuthSwitchStartPreuse } from './authority-contract.js';
|
|
3
4
|
import { isAuthSwitchImportRefusal } from './authority-import-contract.js';
|
|
4
5
|
import type { IAuthSwitchImportInventory, IAuthSwitchImportResult, IAuthSwitchImportStatus,
|
|
5
6
|
IReq_AuthSwitchImportStatus, TAuthSwitchImportAction } from './authority-import-contract.js';
|
|
6
7
|
import { AuthSwitchClient } from './classes.authorityclient.js';
|
|
7
8
|
import { resolveAuthSwitchAuthorityPaths } from './classes.authorityservice.js';
|
|
8
|
-
import { defaultPreusePrompt, validatePreuseOptions } from './preuse.js';
|
|
9
|
+
import { defaultPreusePrompt, describePreuseFailure, describePreuseLimit, validatePreuseOptions } from './preuse.js';
|
|
9
10
|
import { plainText } from './formatting.js';
|
|
10
11
|
import * as plugins from './plugins.js';
|
|
11
12
|
|
|
@@ -23,6 +24,8 @@ export interface IAuthSwitchAuthorityCliClient {
|
|
|
23
24
|
startPreuse(input: IReq_AuthSwitchStartPreuse['request']): Promise<IAuthSwitchPreuseOperation>;
|
|
24
25
|
getPreuse(operationId: string): Promise<IAuthSwitchPreuseOperation>;
|
|
25
26
|
cancelPreuse(operationId: string): Promise<IAuthSwitchPreuseOperation>;
|
|
27
|
+
removeAccount(accountId: string, expectedRevision: number,
|
|
28
|
+
options: { revoke: boolean }): Promise<IReq_AuthSwitchRemoveAccount['response']>;
|
|
26
29
|
close(reason?: Error): void;
|
|
27
30
|
}
|
|
28
31
|
|
|
@@ -44,6 +47,7 @@ type TAuthorityCliCommand =
|
|
|
44
47
|
| { kind: 'importSubmit'; sourceId: string; sourceDigest: string; json: boolean }
|
|
45
48
|
| { kind: 'add'; json: boolean }
|
|
46
49
|
| { kind: 'reauth'; accountId: string; loginId: string; json: boolean }
|
|
50
|
+
| { kind: 'remove'; accountId: string; revoke: boolean; json: boolean }
|
|
47
51
|
| { kind: 'get'; operationId: string; json: boolean }
|
|
48
52
|
| { kind: 'cancel'; operationId: string; json: boolean }
|
|
49
53
|
| { kind: 'resume'; operationId: string; json: boolean }
|
|
@@ -60,7 +64,7 @@ class AuthorityCliFailure extends Error {
|
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
const usage = 'Usage: authswitch account list [--json] | authswitch account add openai [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json]';
|
|
67
|
+
const usage = 'Usage: authswitch account list [--json] | authswitch account add openai [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account remove <account-id> [--revoke] [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json]';
|
|
64
68
|
const safe = (value: string | number | null): string => plainText(value === null ? 'none' : String(value));
|
|
65
69
|
const isId = (value: string | undefined): value is string => Boolean(value && !value.startsWith('-'));
|
|
66
70
|
const isHash = (value: string | undefined): value is string =>
|
|
@@ -143,6 +147,14 @@ const parse = (argv: readonly string[]): TAuthorityCliCommand | null => {
|
|
|
143
147
|
&& (argv.length === 4 || (argv.length === 5 && argv[4] === '--json'))) {
|
|
144
148
|
return { kind: 'reauth', accountId: argv[2], loginId: argv[3], json: argv[4] === '--json' };
|
|
145
149
|
}
|
|
150
|
+
if (argv[0] === 'account' && argv[1] === 'remove' && isId(argv[2])) {
|
|
151
|
+
const options = argv.slice(3);
|
|
152
|
+
const revoke = options.includes('--revoke');
|
|
153
|
+
const json = options.includes('--json');
|
|
154
|
+
if (options.length !== Number(revoke) + Number(json)
|
|
155
|
+
|| options.some(option => option !== '--revoke' && option !== '--json')) return null;
|
|
156
|
+
return { kind: 'remove', accountId: argv[2], revoke, json };
|
|
157
|
+
}
|
|
146
158
|
if (argv[0] === 'account' && argv[1] === 'operation'
|
|
147
159
|
&& (argv[2] === 'get' || argv[2] === 'cancel' || argv[2] === 'resume') && isId(argv[3])
|
|
148
160
|
&& (argv.length === 4 || (argv.length === 5 && argv[4] === '--json'))) {
|
|
@@ -210,11 +222,27 @@ class ScopedAuthorityCliClient implements IAuthSwitchAuthorityCliClient {
|
|
|
210
222
|
return this.client.cancelPreuse(operationId, this.controller.signal);
|
|
211
223
|
}
|
|
212
224
|
|
|
225
|
+
public removeAccount(accountId: string, expectedRevision: number,
|
|
226
|
+
options: { revoke: boolean }): Promise<IReq_AuthSwitchRemoveAccount['response']> {
|
|
227
|
+
return this.client.removeAccount(accountId, expectedRevision, { ...options, signal: this.controller.signal });
|
|
228
|
+
}
|
|
229
|
+
|
|
213
230
|
public close(reason = new Error('Authswitch authority CLI closed.')): void {
|
|
214
231
|
if (!this.controller.signal.aborted) this.controller.abort(reason);
|
|
215
232
|
}
|
|
216
233
|
}
|
|
217
234
|
|
|
235
|
+
const revocationLines: Record<IReq_AuthSwitchRemoveAccount['response']['revoked'], string> = {
|
|
236
|
+
not_requested: 'Its ChatGPT sign-in was not revoked at OpenAI (no --revoke).',
|
|
237
|
+
yes: 'OpenAI confirmed revoking its ChatGPT sign-in.',
|
|
238
|
+
failed: 'OpenAI did not confirm revoking its ChatGPT sign-in; it may still be valid there.',
|
|
239
|
+
not_applicable: 'The authority held no ChatGPT sign-in of its own for it, so nothing was revoked.',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const renderRemoval = (removal: IReq_AuthSwitchRemoveAccount['response']): string =>
|
|
243
|
+
`Removed account ${safe(removal.account.email ?? removal.account.label)} (${safe(removal.account.id)}).\n`
|
|
244
|
+
+ `${revocationLines[removal.revoked]}\n`;
|
|
245
|
+
|
|
218
246
|
const renderList = (snapshot: IAuthSwitchSnapshot): string => {
|
|
219
247
|
const lines = [`Authority accounts — generated ${safe(snapshot.generatedAt)}, revision ${snapshot.revision}`];
|
|
220
248
|
if (snapshot.accounts.length === 0) lines.push('No authority accounts are registered.');
|
|
@@ -332,7 +360,8 @@ const resumeCommand = (operationId: string, json: boolean): string =>
|
|
|
332
360
|
`authswitch account operation resume ${safe(operationId)}${json ? ' --json' : ''}`;
|
|
333
361
|
|
|
334
362
|
const terminalPreuseOperation = (operation: IAuthSwitchPreuseOperation): boolean =>
|
|
335
|
-
operation.state === 'complete' || operation.state === 'not_sent' || operation.state === '
|
|
363
|
+
operation.state === 'complete' || operation.state === 'not_sent' || operation.state === 'refused'
|
|
364
|
+
|| operation.state === 'outcome_unknown';
|
|
336
365
|
|
|
337
366
|
const preuseResumeCommand = (operationId: string, json: boolean): string =>
|
|
338
367
|
`authswitch account preuse operation resume ${safe(operationId)}${json ? ' --json' : ''}`;
|
|
@@ -346,9 +375,9 @@ const renderOperation = (operation: IAuthSwitchOperation, json: boolean): string
|
|
|
346
375
|
if (operation.targetAccountId || operation.targetLoginId) {
|
|
347
376
|
lines.push(` target account=${safe(operation.targetAccountId)}; login=${safe(operation.targetLoginId)}`);
|
|
348
377
|
}
|
|
349
|
-
if (operation.prompt
|
|
378
|
+
if (operation.prompt) {
|
|
350
379
|
lines.push(` Open ${safe(operation.prompt.verificationUrl)}`, ` Code: ${safe(operation.prompt.userCode)}`);
|
|
351
|
-
}
|
|
380
|
+
}
|
|
352
381
|
if (operation.result) {
|
|
353
382
|
lines.push(` completed account=${safe(operation.result.accountId)}; login=${safe(operation.result.loginId)}; account revision=${operation.result.accountRevision}; login generation=${operation.result.loginGeneration}`);
|
|
354
383
|
}
|
|
@@ -368,6 +397,8 @@ const renderPreuseOperation = (operation: IAuthSwitchPreuseOperation, json: bool
|
|
|
368
397
|
if (operation.result) {
|
|
369
398
|
lines.push(` tokens input=${safe(operation.result.inputTokens)}; output=${safe(operation.result.outputTokens)}; total=${safe(operation.result.totalTokens)}`);
|
|
370
399
|
}
|
|
400
|
+
if (operation.limit) lines.push(` ${safe(describePreuseLimit(operation.limit, Date.now()))}`);
|
|
401
|
+
if (operation.failure) lines.push(` Cause: ${safe(describePreuseFailure(operation.failure))}`);
|
|
371
402
|
if (!terminalPreuseOperation(operation)) {
|
|
372
403
|
lines.push(` Resume: ${preuseResumeCommand(operation.id, false)}`);
|
|
373
404
|
}
|
|
@@ -721,6 +752,17 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
|
|
|
721
752
|
await writeOutput(command.json ? JSON.stringify(result) + '\n' : renderImportResult(result));
|
|
722
753
|
return result.status === 'complete' ? 0 : 1;
|
|
723
754
|
}
|
|
755
|
+
if (command.kind === 'remove') {
|
|
756
|
+
const account = (await activeClient.snapshotAll()).accounts.find(item => item.id === command.accountId);
|
|
757
|
+
if (!account) {
|
|
758
|
+
await writeDiagnostic('No authority account has this id. Run `authswitch account list` for the ids.\n');
|
|
759
|
+
return 1;
|
|
760
|
+
}
|
|
761
|
+
const removed = await activeClient.removeAccount(account.id, account.revision, { revoke: command.revoke });
|
|
762
|
+
if (removed.account.id !== account.id || !removed.account.removed) throw new AuthorityCliFailure('response');
|
|
763
|
+
await writeOutput(command.json ? JSON.stringify(removed) + '\n' : renderRemoval(removed));
|
|
764
|
+
return removed.revoked === 'failed' ? 1 : 0;
|
|
765
|
+
}
|
|
724
766
|
if (command.kind === 'preuseGet' || command.kind === 'preuseCancel') {
|
|
725
767
|
const operation = validatePreuseOperation(command.kind === 'preuseGet'
|
|
726
768
|
? await activeClient.getPreuse(command.operationId)
|
|
@@ -11,30 +11,37 @@ import type {
|
|
|
11
11
|
IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
12
12
|
} from './authority-contract.js';
|
|
13
13
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
14
|
-
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
14
|
+
IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
|
|
15
15
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus,
|
|
16
16
|
IReq_AuthSwitchImportSubmit } from './authority-import-contract.js';
|
|
17
17
|
import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
|
|
18
18
|
maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One request on one connection, bounded by its own deadline alone.
|
|
22
|
+
*
|
|
23
|
+
* `TypedRequest.fire` turns each request's `timeoutMs` into the abort of `signal`, so the transport arms no
|
|
24
|
+
* timer of its own: an idle limit here would end a request that was allowed longer -- an import submit is
|
|
25
|
+
* allowed 120 s -- before its deadline. Every authority request carries one; a request without it is refused.
|
|
26
|
+
*/
|
|
20
27
|
const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promise<ITypedRequest> => {
|
|
21
28
|
return new Promise((resolve, reject) => {
|
|
22
|
-
if (signal
|
|
29
|
+
if (!signal) { reject(new Error('Authswitch daemon requests need a deadline.')); return; }
|
|
30
|
+
if (signal.aborted) { reject(signal.reason); return; }
|
|
23
31
|
const socket = plugins.net.createConnection(path);
|
|
24
32
|
let settled = false;
|
|
25
33
|
const frame = new AuthSwitchAuthorityFrameReader();
|
|
26
34
|
const finish = (error?: unknown, response?: ITypedRequest): void => {
|
|
27
35
|
if (settled) return;
|
|
28
36
|
settled = true;
|
|
29
|
-
signal
|
|
37
|
+
signal.removeEventListener('abort', abort);
|
|
30
38
|
socket.destroy();
|
|
31
39
|
if (error) reject(error);
|
|
32
40
|
else if (response) resolve(response);
|
|
33
41
|
else reject(new Error('Authswitch daemon returned no response.'));
|
|
34
42
|
};
|
|
35
|
-
const abort = () => finish(signal
|
|
36
|
-
signal
|
|
37
|
-
socket.setTimeout(35_000, () => finish(new Error('Authswitch daemon request timed out.')));
|
|
43
|
+
const abort = () => finish(signal.reason ?? new Error('Account request was cancelled.'));
|
|
44
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
38
45
|
socket.on('connect', () => {
|
|
39
46
|
const raw = JSON.stringify(payload);
|
|
40
47
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { finish(new Error('Account request is too large.')); return; }
|
|
@@ -380,10 +387,18 @@ export class AuthSwitchClient {
|
|
|
380
387
|
return (await this.request<IReq_AuthSwitchRenameAccount>('authswitch.authority.rename',
|
|
381
388
|
{ accountId, expectedRevision, label }, 35_000, false, signal)).account;
|
|
382
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Removes an account; `revoke: true` also revokes its ChatGPT sign-in at OpenAI (see
|
|
392
|
+
* `IReq_AuthSwitchRemoveAccount`). An older daemon refuses `revoke` as an invalid request before it changes
|
|
393
|
+
* anything, and answers a plain removal without `revoked`, which then is `not_requested`: it never revokes.
|
|
394
|
+
*/
|
|
383
395
|
public async removeAccount(accountId: string, expectedRevision: number,
|
|
384
|
-
signal?: AbortSignal): Promise<IReq_AuthSwitchRemoveAccount['response']
|
|
385
|
-
|
|
386
|
-
|
|
396
|
+
options: { revoke?: boolean; signal?: AbortSignal } = {}): Promise<IReq_AuthSwitchRemoveAccount['response']> {
|
|
397
|
+
const response: Partial<IReq_AuthSwitchRemoveAccount['response']> =
|
|
398
|
+
await this.request<IReq_AuthSwitchRemoveAccount>('authswitch.authority.remove',
|
|
399
|
+
{ accountId, expectedRevision, ...(options.revoke ? { revoke: true as const } : {}) }, 35_000, false,
|
|
400
|
+
options.signal);
|
|
401
|
+
return { account: response.account!, revoked: response.revoked ?? 'not_requested' };
|
|
387
402
|
}
|
|
388
403
|
public async switchClaudeNative(accountId: string, loginId: string,
|
|
389
404
|
signal?: AbortSignal): Promise<IReq_AuthSwitchSwitchClaudeNative['response']['handoff']> {
|
|
@@ -409,6 +424,14 @@ export class AuthSwitchClient {
|
|
|
409
424
|
signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
|
|
410
425
|
return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);
|
|
411
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Releases the holder's own binding by the runtime, scope, incarnation and revision it was bound under,
|
|
429
|
+
* without its capability and without a ready login; `inactive` when no binding matches all four.
|
|
430
|
+
*/
|
|
431
|
+
public unbind(input: IReq_AuthSwitchUnbind['request'],
|
|
432
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchUnbind['response']> {
|
|
433
|
+
return this.request<IReq_AuthSwitchUnbind>('authswitch.authority.unbind', input, 35_000, false, signal);
|
|
434
|
+
}
|
|
412
435
|
public resolveAccess(input: IReq_AuthSwitchResolveAccess['request'],
|
|
413
436
|
signal?: AbortSignal): Promise<IReq_AuthSwitchResolveAccess['response']> {
|
|
414
437
|
return this.request<IReq_AuthSwitchResolveAccess>('authswitch.authority.resolveAccess',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
|
|
2
2
|
import * as plugins from './plugins.js';
|
|
3
|
+
import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
|
|
3
4
|
import type {
|
|
4
5
|
IAuthSwitchDoctorPage, IReq_AuthSwitchDoctor,
|
|
5
6
|
IReq_AuthSwitchBeginAdd, IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
|
|
@@ -10,7 +11,7 @@ import type {
|
|
|
10
11
|
IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
11
12
|
} from './authority-contract.js';
|
|
12
13
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
13
|
-
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
14
|
+
IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
|
|
14
15
|
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
15
16
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
|
|
16
17
|
TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
@@ -40,6 +41,11 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
40
41
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
41
42
|
/** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
|
|
42
43
|
legacyImport: TAuthSwitchAuthorityLegacyImport;
|
|
44
|
+
/**
|
|
45
|
+
* How long an accepted connection has to deliver its complete request, counted from its acceptance and
|
|
46
|
+
* never extended by the bytes it sends: 35 s unless stated.
|
|
47
|
+
*/
|
|
48
|
+
requestDeliveryMs?: number;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
@@ -139,14 +145,19 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
139
145
|
private closing = false;
|
|
140
146
|
/** Every admitted request until its answer is written; shutdown settles these before it closes the store. */
|
|
141
147
|
private readonly inFlight = new Set<Promise<void>>();
|
|
148
|
+
private readonly requestDeliveryMs: number;
|
|
142
149
|
|
|
143
150
|
constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
|
|
144
151
|
// First, before anything is constructed: a daemon that was going to read the wrong host's credentials
|
|
145
152
|
// must not come into existence at all. The type states it and this states it again, because a caller
|
|
146
153
|
// without types is exactly the caller that would have defaulted here.
|
|
147
154
|
assertStatedLegacyImport(options.legacyImport);
|
|
155
|
+
this.requestDeliveryMs = options.requestDeliveryMs ?? 35_000;
|
|
156
|
+
if (!Number.isSafeInteger(this.requestDeliveryMs) || this.requestDeliveryMs <= 0) {
|
|
157
|
+
throw new Error('Authswitch authority requestDeliveryMs must be a positive whole number of milliseconds.');
|
|
158
|
+
}
|
|
148
159
|
this.database = new AuthSwitchAuthorityDatabase(options);
|
|
149
|
-
const provider = options.broker?.provider ?? new
|
|
160
|
+
const provider = options.broker?.provider ?? new AuthSwitchOpenAiProvider();
|
|
150
161
|
this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
|
|
151
162
|
this.usage = new AuthSwitchAuthorityUsage({
|
|
152
163
|
...options.usage,
|
|
@@ -185,8 +196,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
185
196
|
this.register();
|
|
186
197
|
}
|
|
187
198
|
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
/** One exact capability of one binding: what a release fences and what an access resolution holds open. */
|
|
200
|
+
private runtimeBindingKey(bindingId: string, capabilityHash: string): string {
|
|
201
|
+
return `${bindingId}:${capabilityHash}`;
|
|
190
202
|
}
|
|
191
203
|
|
|
192
204
|
private beginRuntimeBindingAccess(key: string): () => void {
|
|
@@ -213,8 +225,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
213
225
|
}
|
|
214
226
|
|
|
215
227
|
private releaseRuntimeBinding(bindingId: string,
|
|
216
|
-
|
|
217
|
-
const key = this.runtimeBindingKey(bindingId,
|
|
228
|
+
capabilityHash: string): Promise<IReq_AuthSwitchReleaseBinding['response']> {
|
|
229
|
+
const key = this.runtimeBindingKey(bindingId, capabilityHash);
|
|
218
230
|
const existing = this.runtimeBindingReleases.get(key);
|
|
219
231
|
if (existing) return existing;
|
|
220
232
|
// Capabilities are freshly random for every bind, including a successor that reuses the
|
|
@@ -222,7 +234,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
222
234
|
this.runtimeBindingReleaseFences.add(key);
|
|
223
235
|
const task = (async (): Promise<IReq_AuthSwitchReleaseBinding['response']> => {
|
|
224
236
|
await this.waitForRuntimeBindingAccess(key);
|
|
225
|
-
const state = await this.broker.
|
|
237
|
+
const state = await this.broker.releaseExternalCapability(bindingId, capabilityHash);
|
|
226
238
|
this.runtimeBindingReleaseFences.delete(key);
|
|
227
239
|
return { state };
|
|
228
240
|
})();
|
|
@@ -463,15 +475,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
463
475
|
}));
|
|
464
476
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
|
|
465
477
|
'authswitch.authority.remove', async request => {
|
|
466
|
-
|
|
467
|
-
|
|
478
|
+
const revoke = Object.hasOwn(request, 'revoke');
|
|
479
|
+
if (!hasKeys(request, revoke ? ['accountId', 'expectedRevision', 'revoke'] : ['accountId', 'expectedRevision'])
|
|
480
|
+
|| typeof request.accountId !== 'string' || typeof request.expectedRevision !== 'number'
|
|
481
|
+
|| (revoke && request.revoke !== true)) invalid();
|
|
468
482
|
if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
|
|
469
483
|
throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
|
|
470
484
|
}
|
|
471
485
|
this.drainingAccounts.add(request.accountId);
|
|
472
486
|
try {
|
|
473
487
|
await this.drainManagedCodexAccount(request.accountId);
|
|
474
|
-
return
|
|
488
|
+
return await this.broker.removeAccount(request.accountId, request.expectedRevision, { revoke });
|
|
475
489
|
} finally { this.drainingAccounts.delete(request.accountId); }
|
|
476
490
|
}));
|
|
477
491
|
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBindAccount>(
|
|
@@ -482,6 +496,17 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
482
496
|
|| typeof request.incarnationId !== 'string') invalid();
|
|
483
497
|
return this.broker.bindAccount(request);
|
|
484
498
|
}));
|
|
499
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUnbind>(
|
|
500
|
+
'authswitch.authority.unbind', async request => {
|
|
501
|
+
if (!hasKeys(request, ['runtime', 'scopeId', 'incarnationId', 'expectedRevision'])
|
|
502
|
+
|| !['flex', 'opencode', 'claude'].includes(request.runtime) || typeof request.scopeId !== 'string'
|
|
503
|
+
|| typeof request.incarnationId !== 'string' || typeof request.expectedRevision !== 'number') invalid();
|
|
504
|
+
// The holder's own binding, found by the identity it bound under whatever its login's state; from
|
|
505
|
+
// here it is released exactly as its capability would release it.
|
|
506
|
+
const held = await this.broker.findHeldBinding(request);
|
|
507
|
+
if (!held) return { state: 'inactive' };
|
|
508
|
+
return this.releaseRuntimeBinding(held.bindingId, held.capabilityHash);
|
|
509
|
+
}));
|
|
485
510
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
|
|
486
511
|
'authswitch.authority.resolveAccess', async request => {
|
|
487
512
|
if ((typeof request !== 'object' || request === null || Array.isArray(request))
|
|
@@ -491,7 +516,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
491
516
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
|
|
492
517
|
|| typeof request.minValidityMs !== 'number'
|
|
493
518
|
|| (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
|
|
494
|
-
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId,
|
|
519
|
+
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId,
|
|
520
|
+
authSwitchBindingCapabilityHash(request.capability)));
|
|
495
521
|
try {
|
|
496
522
|
return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
|
|
497
523
|
request.rejectedGrantGeneration);
|
|
@@ -501,7 +527,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
501
527
|
'authswitch.authority.release', async request => {
|
|
502
528
|
if (!hasKeys(request, ['bindingId', 'capability'])
|
|
503
529
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
|
|
504
|
-
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
530
|
+
return this.releaseRuntimeBinding(request.bindingId, authSwitchBindingCapabilityHash(request.capability));
|
|
505
531
|
}));
|
|
506
532
|
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
|
|
507
533
|
'authswitch.authority.import.submit', async request => {
|
|
@@ -890,13 +916,24 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
890
916
|
await plugins.fs.promises.unlink(path);
|
|
891
917
|
}
|
|
892
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Serves one request on one connection. A connection's failure is its own: a client that went away before
|
|
921
|
+
* its answer -- EPIPE or a reset on the write, a read error -- only destroys that socket, and the close
|
|
922
|
+
* that follows aborts the request it had admitted. Nothing one client does can end the daemon.
|
|
923
|
+
*
|
|
924
|
+
* A connection has one deadline to deliver its complete request, `requestDeliveryMs` from acceptance, which
|
|
925
|
+
* no byte it sends extends: a client dripping a request slowly holds its connection no longer than one that
|
|
926
|
+
* sends nothing. Once admitted, the request runs until it answers or its client goes away: the client's own
|
|
927
|
+
* deadline governs, which may be longer than the delivery deadline.
|
|
928
|
+
*/
|
|
893
929
|
private accept(socket: plugins.net.Socket, router: plugins.typedrequest.TypedRouter): void {
|
|
894
930
|
this.sockets.add(socket);
|
|
895
931
|
const abort = new AbortController();
|
|
896
|
-
|
|
932
|
+
const delivery = setTimeout(() => socket.destroy(), this.requestDeliveryMs);
|
|
933
|
+
socket.on('error', () => socket.destroy());
|
|
934
|
+
socket.on('close', () => { clearTimeout(delivery); this.sockets.delete(socket); abort.abort(); });
|
|
897
935
|
const frame = new AuthSwitchAuthorityFrameReader();
|
|
898
936
|
let handled = false;
|
|
899
|
-
socket.setTimeout(35_000, () => socket.destroy());
|
|
900
937
|
socket.on('data', chunk => {
|
|
901
938
|
if (handled) { socket.destroy(); return; }
|
|
902
939
|
if (!Buffer.isBuffer(chunk)) { socket.destroy(); return; }
|
|
@@ -905,12 +942,15 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
905
942
|
catch { socket.destroy(); return; }
|
|
906
943
|
if (input === null) return;
|
|
907
944
|
handled = true;
|
|
945
|
+
clearTimeout(delivery);
|
|
908
946
|
let request: ITypedRequest;
|
|
909
947
|
try { request = JSON.parse(input) as ITypedRequest; }
|
|
910
948
|
catch { socket.destroy(); return; }
|
|
911
949
|
const answered: Promise<void> = router.routeAndAddResponse(request, { trustedLocalData: { authoritySocket: socket },
|
|
912
950
|
trustedAbortSignal: abort.signal })
|
|
913
951
|
.then(async response => {
|
|
952
|
+
// A client that is already gone is owed no answer.
|
|
953
|
+
if (!socket.writable) return;
|
|
914
954
|
const raw = JSON.stringify(response);
|
|
915
955
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { socket.destroy(); return; }
|
|
916
956
|
await new Promise<void>(resolve => socket.end(raw + '\n', () => resolve()));
|