@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.
Files changed (75) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/authority-contract.d.ts +56 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-runtime-contract.d.ts +29 -0
  5. package/dist_ts/classes.authoritybroker.d.ts +36 -4
  6. package/dist_ts/classes.authoritybroker.js +71 -12
  7. package/dist_ts/classes.authoritycli.d.ts +4 -1
  8. package/dist_ts/classes.authoritycli.js +42 -7
  9. package/dist_ts/classes.authorityclient.d.ts +15 -2
  10. package/dist_ts/classes.authorityclient.js +31 -8
  11. package/dist_ts/classes.authoritydaemon.d.ts +17 -0
  12. package/dist_ts/classes.authoritydaemon.js +50 -14
  13. package/dist_ts/classes.authoritydatabase.d.ts +14 -3
  14. package/dist_ts/classes.authoritydatabase.js +25 -10
  15. package/dist_ts/classes.authorityimport.d.ts +4 -4
  16. package/dist_ts/classes.authorityimport.js +8 -7
  17. package/dist_ts/classes.authoritymodels.d.ts +11 -3
  18. package/dist_ts/classes.authoritymodels.js +43 -13
  19. package/dist_ts/classes.authoritypreuse.d.ts +8 -2
  20. package/dist_ts/classes.authoritypreuse.js +27 -17
  21. package/dist_ts/classes.authorityusage.d.ts +4 -3
  22. package/dist_ts/classes.authorityusage.js +3 -5
  23. package/dist_ts/classes.cli.js +3 -5
  24. package/dist_ts/classes.codexpreuse.d.ts +14 -3
  25. package/dist_ts/classes.codexpreuse.js +127 -44
  26. package/dist_ts/classes.codexstatus.js +44 -95
  27. package/dist_ts/classes.login.d.ts +20 -10
  28. package/dist_ts/classes.login.js +14 -13
  29. package/dist_ts/classes.openaiprovider.d.ts +53 -0
  30. package/dist_ts/classes.openaiprovider.js +340 -0
  31. package/dist_ts/classes.opencodeharness.js +3 -2
  32. package/dist_ts/classes.service.js +3 -5
  33. package/dist_ts/index.d.ts +2 -0
  34. package/dist_ts/index.js +3 -1
  35. package/dist_ts/interfaces.openai.d.ts +118 -0
  36. package/dist_ts/interfaces.openai.js +3 -0
  37. package/dist_ts/openaiauth.d.ts +76 -0
  38. package/dist_ts/openaiauth.js +371 -0
  39. package/dist_ts/openaiusage.d.ts +59 -0
  40. package/dist_ts/openaiusage.js +222 -0
  41. package/dist_ts/plugins.d.ts +1 -3
  42. package/dist_ts/plugins.js +2 -4
  43. package/dist_ts/preuse.d.ts +50 -0
  44. package/dist_ts/preuse.js +63 -4
  45. package/dist_ts/ts_migration/legacysources/shared.js +4 -3
  46. package/openai-codex-license.txt +201 -0
  47. package/package.json +6 -4
  48. package/readme.md +121 -26
  49. package/third-party-notices.md +29 -0
  50. package/ts/00_commitinfo_data.ts +1 -1
  51. package/ts/authority-contract.ts +60 -8
  52. package/ts/authority-runtime-contract.ts +30 -0
  53. package/ts/classes.authoritybroker.ts +80 -17
  54. package/ts/classes.authoritycli.ts +48 -6
  55. package/ts/classes.authorityclient.ts +32 -9
  56. package/ts/classes.authoritydaemon.ts +54 -14
  57. package/ts/classes.authoritydatabase.ts +28 -11
  58. package/ts/classes.authorityimport.ts +12 -12
  59. package/ts/classes.authoritymodels.ts +42 -14
  60. package/ts/classes.authoritypreuse.ts +30 -17
  61. package/ts/classes.authorityusage.ts +8 -9
  62. package/ts/classes.cli.ts +3 -3
  63. package/ts/classes.codexpreuse.ts +130 -40
  64. package/ts/classes.codexstatus.ts +40 -73
  65. package/ts/classes.login.ts +34 -21
  66. package/ts/classes.openaiprovider.ts +365 -0
  67. package/ts/classes.opencodeharness.ts +3 -2
  68. package/ts/classes.service.ts +3 -4
  69. package/ts/index.ts +2 -0
  70. package/ts/interfaces.openai.ts +142 -0
  71. package/ts/openaiauth.ts +422 -0
  72. package/ts/openaiusage.ts +247 -0
  73. package/ts/plugins.ts +1 -3
  74. package/ts/preuse.ts +88 -3
  75. 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 = plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
13
- type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter,
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 plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken).expiresAt ?? null; }
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: plugins.flexAccounts.ISmartAiProviderLoginHandle;
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 plugins.flexAccounts.OpenAiProviderAdapter();
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 = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.idToken);
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: plugins.flexAccounts.ISmartAiProviderLoginHandle;
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
- public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
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
- return publicAccount(account);
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, (existing, account, grant) => {
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: (existing?.revision ?? 0) + 1,
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 (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
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
- authSwitchBindingCapabilityHash(capability));
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 = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
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, IReq_AuthSwitchStartPreuse } from './authority-contract.js';
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 === 'outcome_unknown';
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?.flow === 'device') {
378
+ if (operation.prompt) {
350
379
  lines.push(` Open ${safe(operation.prompt.verificationUrl)}`, ` Code: ${safe(operation.prompt.userCode)}`);
351
- } else if (operation.prompt?.flow === 'browser') lines.push(` Open ${safe(operation.prompt.authUrl)}`);
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?.aborted) { reject(signal.reason); return; }
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?.removeEventListener('abort', abort);
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?.reason ?? new Error('Account request was cancelled.'));
36
- signal?.addEventListener('abort', abort, { once: true });
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']['account']> {
385
- return (await this.request<IReq_AuthSwitchRemoveAccount>('authswitch.authority.remove',
386
- { accountId, expectedRevision }, 35_000, false, signal)).account;
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 plugins.flexAccounts.OpenAiProviderAdapter();
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
- private runtimeBindingKey(bindingId: string, capability: string): string {
189
- return `${bindingId}:${authSwitchBindingCapabilityHash(capability)}`;
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
- capability: string): Promise<IReq_AuthSwitchReleaseBinding['response']> {
217
- const key = this.runtimeBindingKey(bindingId, capability);
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.releaseExternalBinding(bindingId, capability);
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
- if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
467
- || typeof request.expectedRevision !== 'number') invalid();
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 { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
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, request.capability));
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
- socket.on('close', () => { this.sockets.delete(socket); abort.abort(); });
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()));