agl 40.5.1 → 41.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 (137) hide show
  1. package/changelog.md +43 -0
  2. package/dist_serve/bundle.js +3514 -3357
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_serve/bundle.js.third-party-notices.json +58 -32
  5. package/dist_serve/bundle.js.third-party-notices.txt +108 -33
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/aglapi/aglapisurface.document.js +64 -37
  8. package/dist_ts/classes.aglhome.d.ts +5 -0
  9. package/dist_ts/classes.aglhome.js +2 -1
  10. package/dist_ts/classes.authstore.js +5 -1
  11. package/dist_ts/classes.controller.d.ts +8 -0
  12. package/dist_ts/classes.controller.js +119 -22
  13. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  14. package/dist_ts/classes.controllereventdelivery.js +233 -0
  15. package/dist_ts/classes.flexmodels.public.js +4 -1
  16. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  17. package/dist_ts/classes.flexprojectionreader.js +29 -5
  18. package/dist_ts/classes.flexservice.d.ts +2 -1
  19. package/dist_ts/classes.flexservice.js +34 -6
  20. package/dist_ts/classes.flexstore.d.ts +41 -4
  21. package/dist_ts/classes.flexstore.js +266 -203
  22. package/dist_ts/classes.inferencegateway.d.ts +35 -1
  23. package/dist_ts/classes.inferencegateway.js +86 -26
  24. package/dist_ts/classes.inferenceservice.d.ts +4 -2
  25. package/dist_ts/classes.inferenceservice.js +5 -4
  26. package/dist_ts/classes.proxycontrol.d.ts +58 -4
  27. package/dist_ts/classes.proxycontrol.js +23 -44
  28. package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
  29. package/dist_ts/classes.proxycontrolclient.js +67 -17
  30. package/dist_ts/classes.proxydaemon.d.ts +22 -3
  31. package/dist_ts/classes.proxydaemon.js +73 -12
  32. package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
  33. package/dist_ts/classes.proxyleaseholder.js +66 -22
  34. package/dist_ts/classes.proxystore.d.ts +9 -0
  35. package/dist_ts/classes.proxystore.js +15 -7
  36. package/dist_ts/classes.ticketnotifier.js +4 -2
  37. package/dist_ts/classes.ticketstore.d.ts +50 -2
  38. package/dist_ts/classes.ticketstore.js +195 -78
  39. package/dist_ts/classes.workercallerbindings.d.ts +66 -0
  40. package/dist_ts/classes.workercallerbindings.js +211 -0
  41. package/dist_ts/classes.workercallers.d.ts +63 -2
  42. package/dist_ts/classes.workercallers.js +430 -65
  43. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  44. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  45. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  46. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  47. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  48. package/dist_ts/driver/functions.drivertools.js +19 -9
  49. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  50. package/dist_ts/fleet/classes.boardpump.js +38 -20
  51. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  52. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  53. package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
  54. package/dist_ts/fleet/classes.workerlaunches.js +25 -19
  55. package/dist_ts/fleet/classes.workers.d.ts +21 -0
  56. package/dist_ts/fleet/classes.workers.js +110 -17
  57. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  58. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  59. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  60. package/dist_ts/functions.inferencecli.d.ts +3 -2
  61. package/dist_ts/functions.inferencecli.js +67 -5
  62. package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
  63. package/dist_ts/functions.inferencecliarguments.js +29 -2
  64. package/dist_ts/functions.inferencelaunch.d.ts +24 -0
  65. package/dist_ts/functions.inferencelaunch.js +42 -2
  66. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  67. package/dist_ts/functions.mcptickettools.js +2 -2
  68. package/dist_ts/functions.proxycontract.d.ts +11 -0
  69. package/dist_ts/functions.proxycontract.js +12 -1
  70. package/dist_ts/functions.proxysocket.d.ts +6 -0
  71. package/dist_ts/functions.proxysocket.js +46 -0
  72. package/dist_ts/functions.ticketcli.d.ts +1 -1
  73. package/dist_ts/functions.ticketcli.js +8 -4
  74. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  75. package/dist_ts/functions.ticketvalidation.js +7 -4
  76. package/dist_ts/functions.workerprocessproof.d.ts +6 -0
  77. package/dist_ts/functions.workerprocessproof.js +28 -4
  78. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  79. package/dist_ts/interfaces.flexipc.js +33 -16
  80. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  81. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  82. package/dist_ts_interfaces/interfaces.js +1 -1
  83. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  84. package/dist_ts_interfaces/tickets.d.ts +37 -9
  85. package/dist_ts_interfaces/tickets.js +7 -1
  86. package/dist_ts_interfaces/workercallers.d.ts +43 -4
  87. package/package.json +7 -7
  88. package/readme.md +156 -23
  89. package/ts/00_commitinfo_data.ts +1 -1
  90. package/ts/aglapi/aglapisurface.document.ts +63 -36
  91. package/ts/classes.aglhome.ts +6 -0
  92. package/ts/classes.authstore.ts +4 -0
  93. package/ts/classes.controller.ts +141 -32
  94. package/ts/classes.controllereventdelivery.ts +289 -0
  95. package/ts/classes.flexmodels.public.ts +10 -0
  96. package/ts/classes.flexprojectionreader.ts +30 -3
  97. package/ts/classes.flexservice.ts +33 -5
  98. package/ts/classes.flexstore.ts +381 -246
  99. package/ts/classes.inferencegateway.ts +104 -23
  100. package/ts/classes.inferenceservice.ts +6 -3
  101. package/ts/classes.proxycontrol.ts +76 -39
  102. package/ts/classes.proxycontrolclient.ts +72 -20
  103. package/ts/classes.proxydaemon.ts +85 -15
  104. package/ts/classes.proxyleaseholder.ts +84 -28
  105. package/ts/classes.proxystore.ts +16 -5
  106. package/ts/classes.ticketnotifier.ts +3 -1
  107. package/ts/classes.ticketstore.ts +201 -75
  108. package/ts/classes.workercallerbindings.ts +147 -0
  109. package/ts/classes.workercallers.ts +410 -59
  110. package/ts/driver/classes.drivereventrelay.ts +89 -4
  111. package/ts/driver/functions.driverinstructions.ts +1 -1
  112. package/ts/driver/functions.drivertools.ts +22 -8
  113. package/ts/fleet/classes.boardpump.ts +51 -19
  114. package/ts/fleet/classes.ticketeffects.ts +25 -11
  115. package/ts/fleet/classes.workerlaunches.ts +23 -15
  116. package/ts/fleet/classes.workers.ts +79 -17
  117. package/ts/fleet/functions.workervalidation.ts +1 -1
  118. package/ts/functions.flexprivatesnapshot.ts +601 -0
  119. package/ts/functions.inferencecli.ts +61 -7
  120. package/ts/functions.inferencecliarguments.ts +32 -1
  121. package/ts/functions.inferencelaunch.ts +49 -2
  122. package/ts/functions.mcptickettools.ts +1 -1
  123. package/ts/functions.proxycontract.ts +12 -0
  124. package/ts/functions.proxysocket.ts +39 -0
  125. package/ts/functions.ticketcli.ts +7 -3
  126. package/ts/functions.ticketvalidation.ts +7 -3
  127. package/ts/functions.workerprocessproof.ts +27 -4
  128. package/ts/interfaces.flexipc.ts +50 -21
  129. package/ts_interfaces/fleetworkers.ts +6 -0
  130. package/ts_interfaces/interfaces.ts +1 -1
  131. package/ts_interfaces/requests.tickets.ts +9 -6
  132. package/ts_interfaces/tickets.ts +32 -1
  133. package/ts_interfaces/workercallers.ts +32 -4
  134. package/ts_web/00_commitinfo_data.ts +1 -1
  135. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  136. package/ts_web/elements.ticketboard.ts +16 -4
  137. package/ts_web/settings.general.ts +10 -4
@@ -13,6 +13,13 @@ import { inferenceAnthropicCheckedBytes, inferenceAnthropicServerToolTypes, infe
13
13
  inferenceAnthropicUnpricedRequest, inferenceOpenAiCheckedBytes, inferenceOpenAiSpend,
14
14
  inferenceOpenAiUnpricedRequest } from './classes.inferencespend.js';
15
15
  import { usageSessionPattern, type IInferenceTokenCounts, type IInferenceUsageLedger } from './classes.inferenceusage.js';
16
+ import { claimProxySocketPath } from './functions.proxysocket.js';
17
+
18
+ /**
19
+ * Where a credential is accepted: the loopback listener, which every process on the host can reach, or the confined
20
+ * listener, a Unix socket only the containers it is mounted into have. Each credential is accepted on its own route alone.
21
+ */
22
+ export type TInferenceGatewayRoute = 'loopback' | 'confined';
16
23
 
17
24
  /**
18
25
  * Requests in flight: per session, so one runaway conversation cannot starve the others; per client credential,
@@ -57,6 +64,13 @@ export interface IInferenceGatewayOptions {
57
64
  * and translated Messages answers alike. It holds no prompt, answer or credential.
58
65
  */
59
66
  usageLedger: IInferenceUsageLedger;
67
+ /**
68
+ * The confined listener's Unix socket, bound beside the loopback port and made this user's alone (0600, in a private
69
+ * directory that holds nothing else). It accepts confined credentials alone and serves them the Claude pool's three
70
+ * endpoints Claude Code uses (`confinedEndpoints`), exactly as the loopback listener serves them; unset, the gateway has
71
+ * no confined listener and admits no confined credential.
72
+ */
73
+ confinedSocketPath?: string;
60
74
  }
61
75
 
62
76
  /** A placement the journal could not record, as the proxy reports it: no session id, prompt or account id. */
@@ -144,6 +158,11 @@ export interface IInferenceGatewayClientOptions {
144
158
  * sessions are its name's. Sessions are kept per pool (see `inferenceSessionKey`).
145
159
  */
146
160
  sessionScope?: string;
161
+ /**
162
+ * Where the credential is accepted; `loopback` when unset. A confined credential is a lease of the Claude pool, accepted
163
+ * on the confined listener alone.
164
+ */
165
+ route?: TInferenceGatewayRoute;
147
166
  }
148
167
 
149
168
  /** A credential the gateway admits, known to it by its digest alone. */
@@ -166,6 +185,8 @@ export interface IInferenceGatewayRestoredLease {
166
185
  digest: string;
167
186
  /** The lease's `sessionScope`; see `IInferenceGatewayClientOptions`. */
168
187
  sessionScope?: string;
188
+ /** The lease's `route`; see `IInferenceGatewayClientOptions`. */
189
+ route?: TInferenceGatewayRoute;
169
190
  }
170
191
 
171
192
  interface IClient {
@@ -176,6 +197,8 @@ interface IClient {
176
197
  name?: string;
177
198
  /** Whose sessions the client's session ids name; see `inferenceSessionKey`. */
178
199
  sessionScope: string;
200
+ /** The one listener the credential is accepted on. */
201
+ route: TInferenceGatewayRoute;
179
202
  lastUsedAt?: number;
180
203
  /** The Codex version this client last read its catalog for; its responses then report the current catalog tag. */
181
204
  codexClientVersion?: string;
@@ -209,6 +232,12 @@ const isCodexResponseHeader = (nameArg: string): boolean => nameArg.startsWith('
209
232
  const maximumErrorBodyBytes = 64 * 1024;
210
233
  const catalogTag = (modelsArg: unknown): string =>
211
234
  `"${plugins.crypto.createHash('sha256').update(JSON.stringify(modelsArg)).digest('base64url')}"`;
235
+ /**
236
+ * What the confined listener serves, by method and path: the Claude pool's endpoints Claude Code uses. Every other
237
+ * method, path and pool is refused there before anything is read or routed.
238
+ */
239
+ const confinedEndpoints: ReadonlySet<string> = new Set(['GET /anthropic/v1/models', 'POST /anthropic/v1/messages',
240
+ 'POST /anthropic/v1/messages/count_tokens']);
212
241
  const turnStateLimit = 4096;
213
242
  const turnStateLifetimeMs = 60 * 60 * 1000;
214
243
  const clientVersionPattern = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:[-+][0-9A-Za-z.-]{1,32})?$/;
@@ -447,7 +476,19 @@ export const assertInferenceRoutingPolicy = (policyArg: IInferenceRoutingPolicy)
447
476
  }
448
477
  };
449
478
 
450
- /** Authenticated, loopback-only Responses and Messages endpoints with bounded, cancelable streaming. */
479
+ /** A confined credential is a lease of the Claude pool, admitted only by a gateway with a confined listener. */
480
+ const assertConfinedClient = (policyArg: IInferenceRoutingPolicy, nameArg: string | undefined,
481
+ socketPathArg: string | undefined): void => {
482
+ if (socketPathArg === undefined) throw new Error('The gateway has no confined listener.');
483
+ if (nameArg !== undefined || policyArg.provider !== 'anthropic') {
484
+ throw new Error('A confined credential is a lease of the Claude pool.');
485
+ }
486
+ };
487
+
488
+ /**
489
+ * Authenticated Responses and Messages endpoints with bounded, cancelable streaming, on a loopback listener and, for
490
+ * confined credentials, a Unix socket that serves the Claude pool alone.
491
+ */
451
492
  export class InferenceGateway {
452
493
  private readonly clients = new Set<IClient>();
453
494
  private readonly abort = new AbortController();
@@ -461,6 +502,7 @@ export class InferenceGateway {
461
502
  private inFlight = 0;
462
503
  private readonly limits: IInferenceGatewayLimits;
463
504
  private server?: plugins.http.Server;
505
+ private confinedServer?: plugins.http.Server;
464
506
  private host = '';
465
507
  private startTask?: Promise<void>;
466
508
  private stopTask?: Promise<void>;
@@ -507,7 +549,8 @@ export class InferenceGateway {
507
549
  }
508
550
  const token = clientArg.token ?? plugins.crypto.randomBytes(32).toString('base64url');
509
551
  if (Buffer.byteLength(token) < 32 || Buffer.byteLength(token) > 512) throw new Error('The gateway credential must have 32 to 512 bytes.');
510
- const grant = await this.admit(policyArg, digest(token), clientArg.name ?? clientArg.sessionScope, clientArg.name);
552
+ const grant = await this.admit(policyArg, digest(token), clientArg.name ?? clientArg.sessionScope, clientArg.name,
553
+ clientArg.route ?? 'loopback');
511
554
  return { ...grant, token };
512
555
  }
513
556
 
@@ -521,24 +564,26 @@ export class InferenceGateway {
521
564
  if (leaseArg.sessionScope !== undefined && !clientNamePattern.test(leaseArg.sessionScope)) {
522
565
  throw new Error('A gateway client name is invalid.');
523
566
  }
524
- return this.admit(policyArg, Buffer.from(leaseArg.digest, 'hex'), leaseArg.sessionScope, undefined);
567
+ return this.admit(policyArg, Buffer.from(leaseArg.digest, 'hex'), leaseArg.sessionScope, undefined, leaseArg.route ?? 'loopback');
525
568
  }
526
569
 
527
570
  private async admit(policyArg: IInferenceRoutingPolicy, digestArg: Buffer, scopeNameArg: string | undefined,
528
- nameArg: string | undefined): Promise<IInferenceGatewayGrant> {
571
+ nameArg: string | undefined, routeArg: TInferenceGatewayRoute): Promise<IInferenceGatewayGrant> {
529
572
  assertInferenceRoutingPolicy(policyArg);
573
+ if (routeArg === 'confined') assertConfinedClient(policyArg, nameArg, this.options.confinedSocketPath);
530
574
  await (this.startTask ??= this.start());
531
575
  this.abort.signal.throwIfAborted();
532
576
  if (this.clients.size >= 128) throw new Error('The gateway has reached its client limit.');
533
577
  const client: IClient = { digest: digestArg, policy: structuredClone(policyArg), abort: new AbortController(), pending: 0,
534
578
  sessionScope: scopeNameArg === undefined ? `credential:${digestArg.toString('hex')}` : `client:${scopeNameArg}`,
535
- ...(nameArg === undefined ? {} : { name: nameArg }) };
579
+ route: routeArg, ...(nameArg === undefined ? {} : { name: nameArg }) };
536
580
  if ([...this.clients].some((entry) => plugins.crypto.timingSafeEqual(entry.digest, client.digest))) {
537
581
  throw new Error('A gateway credential must name exactly one client.');
538
582
  }
539
583
  this.clients.add(client);
540
584
  return { baseUrl: `http://${this.host}`, digest: digestArg.toString('hex'), setPolicy: (nextArg) => {
541
585
  assertInferenceRoutingPolicy(nextArg);
586
+ if (routeArg === 'confined') assertConfinedClient(nextArg, nameArg, this.options.confinedSocketPath);
542
587
  client.policy = structuredClone(nextArg);
543
588
  }, close: () => {
544
589
  this.clients.delete(client);
@@ -554,27 +599,58 @@ export class InferenceGateway {
554
599
  this.turnStates.clear();
555
600
  this.sessionsInFlight.clear();
556
601
  await this.startTask?.catch(() => undefined);
557
- if (this.server) await new Promise<void>((resolve) => {
558
- this.server!.close(() => resolve());
559
- this.server!.closeAllConnections();
560
- });
602
+ for (const server of [this.server, this.confinedServer]) {
603
+ if (server) await new Promise<void>((resolve) => {
604
+ server.close(() => resolve());
605
+ server.closeAllConnections();
606
+ });
607
+ }
561
608
  await Promise.allSettled(this.pending);
562
609
  await Promise.allSettled(this.streams);
563
610
  await Promise.allSettled(this.placementWrites);
564
611
  })();
565
612
  }
566
613
 
614
+ /**
615
+ * Binds the loopback listener, then the confined one when the gateway has its socket. A stop that comes meanwhile
616
+ * closes whatever was bound.
617
+ */
567
618
  private async start(): Promise<void> {
619
+ const server = this.createServer('loopback');
620
+ this.server = server;
621
+ await new Promise<void>((resolve, reject) => {
622
+ server.once('error', reject);
623
+ server.listen(this.options.port ?? 0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
624
+ });
625
+ const address = server.address();
626
+ if (!address || typeof address === 'string') throw new Error('The gateway did not acquire a loopback port.');
627
+ this.host = `127.0.0.1:${address.port}`;
628
+ const socketPath = this.options.confinedSocketPath;
629
+ if (socketPath === undefined) return;
630
+ this.abort.signal.throwIfAborted();
631
+ await claimProxySocketPath(socketPath, 'confined proxy socket', 'Another AGL proxy serves the confined proxy socket.');
632
+ this.abort.signal.throwIfAborted();
633
+ const confined = this.createServer('confined');
634
+ this.confinedServer = confined;
635
+ await new Promise<void>((resolve, reject) => {
636
+ confined.once('error', reject);
637
+ confined.listen(socketPath, () => { confined.off('error', reject); resolve(); });
638
+ });
639
+ // The directory is this user's alone already; the socket is too, so only processes of the proxy's user connect.
640
+ await plugins.fs.promises.chmod(socketPath, 0o600);
641
+ }
642
+
643
+ /** One listener: the loopback port or the confined socket, each with the same bounds and handling. */
644
+ private createServer(routeArg: TInferenceGatewayRoute): plugins.http.Server {
568
645
  const server = plugins.http.createServer({ maxHeaderSize: inferenceGatewayMaximumHeaderBytes },
569
646
  plugins.anthropicMessagesNode.createAnthropicMessagesNodeListener({
570
647
  handle: (requestArg) => {
571
- const pending = this.handle(requestArg);
648
+ const pending = this.handle(requestArg, routeArg);
572
649
  this.pending.add(pending);
573
650
  void pending.finally(() => this.pending.delete(pending)).catch(() => undefined);
574
651
  return pending;
575
652
  },
576
653
  }));
577
- this.server = server;
578
654
  const rejectedSockets = new WeakSet<plugins.stream.Duplex>();
579
655
  const activeSockets = new WeakMap<plugins.stream.Duplex, Set<plugins.http.ServerResponse>>();
580
656
  server.on('request', (requestArg, responseArg) => {
@@ -604,17 +680,16 @@ export class InferenceGateway {
604
680
  server.requestTimeout = 60_000;
605
681
  // Idle keep-alive connections of every client count here as well as requests in flight.
606
682
  server.maxConnections = this.limits.total * 2;
607
- await new Promise<void>((resolve, reject) => {
608
- server.once('error', reject);
609
- server.listen(this.options.port ?? 0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
610
- });
611
- const address = server.address();
612
- if (!address || typeof address === 'string') throw new Error('The gateway did not acquire a loopback port.');
613
- this.host = `127.0.0.1:${address.port}`;
683
+ return server;
614
684
  }
615
685
 
616
- private authenticate(requestArg: Request): IClient {
617
- if (requestArg.headers.get('host') !== this.host || requestArg.headers.has('origin')) {
686
+ /**
687
+ * The client a request's credential names, among those accepted on the listener it came in on. A loopback request must
688
+ * name the listener's exact host; on the confined socket the host is irrelevant, nothing but a mounted container
689
+ * reaches it. Neither takes a browser's request. A credential of the other listener is refused like an unknown one.
690
+ */
691
+ private authenticate(requestArg: Request, routeArg: TInferenceGatewayRoute): IClient {
692
+ if ((routeArg === 'loopback' && requestArg.headers.get('host') !== this.host) || requestArg.headers.has('origin')) {
618
693
  throw new InferenceRoutingError(403, 'forbidden', 'The inference gateway accepts local harness requests only.');
619
694
  }
620
695
  const authorization = requestArg.headers.get('authorization');
@@ -622,13 +697,13 @@ export class InferenceGateway {
622
697
  const presented = token && token.length <= 512 ? digest(token) : undefined;
623
698
  let client: IClient | undefined;
624
699
  if (presented) for (const candidate of this.clients) {
625
- if (plugins.crypto.timingSafeEqual(candidate.digest, presented)) client = candidate;
700
+ if (plugins.crypto.timingSafeEqual(candidate.digest, presented) && candidate.route === routeArg) client = candidate;
626
701
  }
627
702
  if (!client) throw new InferenceRoutingError(401, 'authentication_error', 'The gateway credential is invalid or expired.');
628
703
  return client;
629
704
  }
630
705
 
631
- private async handle(requestArg: Request): Promise<Response> {
706
+ private async handle(requestArg: Request, routeArg: TInferenceGatewayRoute): Promise<Response> {
632
707
  let client: IClient | undefined;
633
708
  let transferred = false;
634
709
  let admittedRequest = false;
@@ -636,7 +711,13 @@ export class InferenceGateway {
636
711
  let releaseRoute: (() => void) | undefined;
637
712
  let protocol: TProtocol | undefined;
638
713
  try {
639
- client = this.authenticate(requestArg);
714
+ if (routeArg === 'confined') protocol = 'claude';
715
+ client = this.authenticate(requestArg, routeArg);
716
+ if (routeArg === 'confined' && (client.policy.provider !== 'anthropic'
717
+ || !confinedEndpoints.has(`${requestArg.method} ${new URL(requestArg.url).pathname}`))) {
718
+ throw new InferenceRoutingError(404, 'not_found', 'The confined listener serves the Claude pool\'s '
719
+ + 'GET /anthropic/v1/models, POST /anthropic/v1/messages and POST /anthropic/v1/messages/count_tokens alone.');
720
+ }
640
721
  if (this.inFlight >= this.limits.total) throw new InferenceRoutingError(429, 'concurrency_limit', 'The gateway has too many requests in flight.', 1);
641
722
  if (client.pending >= this.limits.perClient) throw new InferenceRoutingError(429, 'concurrency_limit', 'This gateway client has too many requests.', 1);
642
723
  client.pending += 1;
@@ -34,6 +34,8 @@ export interface IInferenceServiceOptions {
34
34
  spendStore: IInferenceSpendStore;
35
35
  providerFetch?: typeof fetch;
36
36
  limits?: IInferenceGatewayLimits;
37
+ /** The confined listener's socket (see `IInferenceGatewayOptions.confinedSocketPath`); none when unset. */
38
+ confinedSocketPath?: string;
37
39
  }
38
40
 
39
41
  /** How many of the latest placements the status shows. */
@@ -135,7 +137,8 @@ export class InferenceService {
135
137
  });
136
138
  this.gateway = new InferenceGateway({ router: this.router, signatureKey: options.signatureKey,
137
139
  affinity: options.affinity, placements: options.placements, usageLedger: options.usageLedger, port: options.port,
138
- ...(options.limits ? { limits: options.limits } : {}) });
140
+ ...(options.limits ? { limits: options.limits } : {}),
141
+ ...(options.confinedSocketPath === undefined ? {} : { confinedSocketPath: options.confinedSocketPath }) });
139
142
  }
140
143
 
141
144
  /**
@@ -156,8 +159,8 @@ export class InferenceService {
156
159
  }
157
160
 
158
161
  /**
159
- * Takes the port, then reclaims the bindings an earlier run of this installation left: acquiring the port
160
- * comes before authority reads, cleanup or bindings, so a second process cannot fence this one.
162
+ * Takes the port and the confined socket, then reclaims the bindings an earlier run of this installation left:
163
+ * acquiring the port comes before authority reads, cleanup or bindings, so a second process cannot fence this one.
161
164
  */
162
165
  public async start(signalArg?: AbortSignal): Promise<string> {
163
166
  if (this.started || this.stopTask) throw new Error('The gateway service cannot be started twice.');
@@ -6,6 +6,7 @@ import { isInferenceUsageRequest, type IInferenceUsageChangesRequest, type IInfe
6
6
  import type { IInferenceAccountStatus, IInferenceRoutingPolicy, TInferenceClaudeModel,
7
7
  TInferenceModel } from './classes.inferencerouter.js';
8
8
  import {
9
+ aglProxyConfinedIdPattern,
9
10
  inferenceCredentials,
10
11
  inferenceProviders,
11
12
  proxyClientNamePattern,
@@ -16,6 +17,7 @@ import {
16
17
  } from './functions.proxycontract.js';
17
18
  import { isAuthorityAccountId } from '../ts_interfaces/functions.authorityscope.js';
18
19
  import { proxyControlMaximumFrameBytes, readProxyControlLine } from './classes.proxycontrolclient.js';
20
+ import { claimProxySocketPath } from './functions.proxysocket.js';
19
21
 
20
22
  type ITypedRequest = plugins.typedrequestInterfaces.ITypedRequest;
21
23
 
@@ -60,6 +62,19 @@ export interface IAglProxyStatus {
60
62
  nextPlacement: Record<TInferenceProvider, string | null>;
61
63
  /** The latest sessions placed or moved, newest first, with why (at most 20). */
62
64
  recentPlacements: IInferencePlacementStatus[];
65
+ /** The confined listener's socket, or `null` for a proxy without one. */
66
+ confinedSocketPath: string | null;
67
+ /** The confined runs whose credential the proxy serves, oldest first. */
68
+ confinedRuns: IAglProxyConfinedRunStatus[];
69
+ }
70
+
71
+ /** A confined run whose credential the proxy serves (see `IReq_AglProxyConfined`). */
72
+ export interface IAglProxyConfinedRunStatus {
73
+ /** The run's id: the first 12 hex characters of its credential's SHA-256 digest, never the credential. */
74
+ id: string;
75
+ createdAt: string;
76
+ /** False while a restarted proxy serves the credential for its reclaim window, waiting for its holder to take it back. */
77
+ held: boolean;
63
78
  }
64
79
 
65
80
  /**
@@ -119,13 +134,48 @@ export interface IReq_AglProxyLease extends ITypedRequest {
119
134
  /**
120
135
  * Takes back a lease a restarted proxy kept, by its credential: the lease then lives as long as this connection, with
121
136
  * the policy and sessions it was issued with. A credential the proxy does not keep unclaimed is refused alike, whether
122
- * it is unknown, expired or still held.
137
+ * it is unknown, expired or still held. A confined run's credential is taken back the same way.
123
138
  */
124
139
  export interface IReq_AglProxyReclaim extends ITypedRequest {
125
140
  method: 'agl.proxy.reclaim';
126
141
  request: { token: string };
127
- /** Where the lease is served now; a holder whose child was configured with another address cannot continue. */
128
- response: { baseUrl: string };
142
+ /**
143
+ * Where the lease is served now; a holder whose child was configured with another address cannot continue. A confined
144
+ * run's credential answers its confined grant's `baseUrl` and `socketPath`.
145
+ */
146
+ response: { baseUrl: string; socketPath?: string };
147
+ }
148
+
149
+ /**
150
+ * A confined run's credential: accepted on the confined listener alone, never on the loopback one, and served there from
151
+ * the Claude pool exactly as the loopback listener serves a Claude client, its sessions those of the client
152
+ * `aglProxyConfinedClient`. Like a lease it lives as long as the connection that asked for it, a restarted proxy keeps it
153
+ * for its holder to take back (`agl.proxy.reclaim`), and the proxy keeps only its digest. `agl.proxy.confined.end` ends it
154
+ * at once.
155
+ */
156
+ export interface IReq_AglProxyConfined extends ITypedRequest {
157
+ method: 'agl.proxy.confined';
158
+ request: Record<string, never>;
159
+ response: IAglProxyConfinedGrant;
160
+ }
161
+
162
+ /** What a confined run is given: its id, its credential, the confined listener's socket and the origin to name with it. */
163
+ export interface IAglProxyConfinedGrant {
164
+ /** See `IAglProxyConfinedRunStatus.id`. */
165
+ id: string;
166
+ token: string;
167
+ socketPath: string;
168
+ baseUrl: string;
169
+ }
170
+
171
+ /**
172
+ * Ends a confined run's credential at once, by the run's id: the proxy refuses it from then on, forgets it, and closes
173
+ * its holder's connection, telling the holder why.
174
+ */
175
+ export interface IReq_AglProxyConfinedEnd extends ITypedRequest {
176
+ method: 'agl.proxy.confined.end';
177
+ request: { id: string };
178
+ response: { id: string };
129
179
  }
130
180
 
131
181
  /**
@@ -144,6 +194,11 @@ export interface IAglProxyConnection {
144
194
  onClose(listenerArg: () => void): void;
145
195
  /** Aborts when the connection closes, so work for a client that went away stops. */
146
196
  readonly signal: AbortSignal;
197
+ /**
198
+ * Closes a lease's connection from the proxy's side, after one last line that tells its holder why the proxy ended the
199
+ * lease (`{"ended": reasonArg}`), so the holder reports that instead of taking the lease back.
200
+ */
201
+ end(reasonArg: string): void;
147
202
  }
148
203
 
149
204
  export interface IAglProxyControlHandlers {
@@ -152,11 +207,13 @@ export interface IAglProxyControlHandlers {
152
207
  catalog(requestArg: IReq_AglProxyCatalog['request'], connectionArg: IAglProxyConnection): Promise<IReq_AglProxyCatalog['response']>;
153
208
  lease(requestArg: IReq_AglProxyLease['request'], connectionArg: IAglProxyConnection): Promise<IReq_AglProxyLease['response']>;
154
209
  reclaim(requestArg: IReq_AglProxyReclaim['request'], connectionArg: IAglProxyConnection): Promise<IReq_AglProxyReclaim['response']>;
210
+ confined(connectionArg: IAglProxyConnection): Promise<IReq_AglProxyConfined['response']>;
211
+ confinedEnd(requestArg: IReq_AglProxyConfinedEnd['request']): Promise<IReq_AglProxyConfinedEnd['response']>;
155
212
  usage(requestArg: IReq_AglProxyUsage['request'], connectionArg: IAglProxyConnection): Promise<IReq_AglProxyUsage['response']>;
156
213
  }
157
214
 
158
215
  /** The requests whose answered connection stays open: it holds the lease the request was answered with. */
159
- const leaseHoldingMethods: ReadonlySet<string> = new Set(['agl.proxy.lease', 'agl.proxy.reclaim']);
216
+ const leaseHoldingMethods: ReadonlySet<string> = new Set(['agl.proxy.lease', 'agl.proxy.reclaim', 'agl.proxy.confined']);
160
217
  /** A lease credential the proxy issues is 43 characters; one it was given for a named client at most 512 bytes. */
161
218
  const maximumReclaimTokenLength = 512;
162
219
 
@@ -245,6 +302,17 @@ export class AglProxyControlServer {
245
302
  }
246
303
  return this.handlers!.reclaim(requestArg, connectionArg);
247
304
  }));
305
+ this.router.addTypedHandler(handler<IReq_AglProxyConfined>('agl.proxy.confined', (requestArg, connectionArg) => {
306
+ if (!isRecord(requestArg) || Object.keys(requestArg).length !== 0) throw new AglProxyControlRefusal('Invalid proxy confined request.');
307
+ return this.handlers!.confined(connectionArg);
308
+ }));
309
+ this.router.addTypedHandler(handler<IReq_AglProxyConfinedEnd>('agl.proxy.confined.end', (requestArg) => {
310
+ if (!isRecord(requestArg) || typeof requestArg.id !== 'string' || !aglProxyConfinedIdPattern.test(requestArg.id)
311
+ || Object.keys(requestArg).length !== 1) {
312
+ throw new AglProxyControlRefusal('Invalid proxy confined end request.');
313
+ }
314
+ return this.handlers!.confinedEnd(requestArg);
315
+ }));
248
316
  this.router.addTypedHandler(handler<IReq_AglProxyUsage>('agl.proxy.usage', (requestArg, connectionArg) => {
249
317
  if (!isInferenceUsageRequest(requestArg)) throw new AglProxyControlRefusal('Invalid proxy usage request.');
250
318
  return this.handlers!.usage(requestArg, connectionArg);
@@ -258,14 +326,7 @@ export class AglProxyControlServer {
258
326
  }
259
327
 
260
328
  private async listenOnce(): Promise<void> {
261
- const directory = plugins.path.dirname(this.socketPath);
262
- await plugins.fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
263
- const stat = await plugins.fs.promises.lstat(directory);
264
- if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.()) {
265
- throw new Error('The proxy socket directory is not private.');
266
- }
267
- await plugins.fs.promises.chmod(directory, 0o700);
268
- await this.clearStaleSocket();
329
+ await claimProxySocketPath(this.socketPath, 'proxy socket', 'Another AGL proxy is already running for this AGL home.');
269
330
  const server = plugins.net.createServer((socketArg) => this.accept(socketArg));
270
331
  await new Promise<void>((resolve, reject) => {
271
332
  server.once('error', reject);
@@ -305,33 +366,6 @@ export class AglProxyControlServer {
305
366
  if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
306
367
  }
307
368
 
308
- /** A pathname a killed proxy left: removed only while it is an unchanged, owned socket nobody answers on. */
309
- private async clearStaleSocket(): Promise<void> {
310
- let before: plugins.fs.Stats;
311
- try { before = await plugins.fs.promises.lstat(this.socketPath); }
312
- catch (errorArg) {
313
- if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return;
314
- throw errorArg;
315
- }
316
- if (!before.isSocket() || before.uid !== process.getuid?.()) throw new Error('The proxy socket path is unsafe.');
317
- const refused = await new Promise<boolean>((resolve, reject) => {
318
- const probe = plugins.net.createConnection(this.socketPath);
319
- probe.setTimeout(1_000, () => { probe.destroy(); reject(new Error('The proxy socket probe timed out.')); });
320
- probe.once('connect', () => { probe.destroy(); resolve(false); });
321
- probe.once('error', (errorArg) => {
322
- probe.destroy();
323
- if ((errorArg as NodeJS.ErrnoException).code === 'ECONNREFUSED') resolve(true);
324
- else reject(errorArg);
325
- });
326
- });
327
- if (!refused) throw new Error('Another AGL proxy is already running for this AGL home.');
328
- const after = await plugins.fs.promises.lstat(this.socketPath);
329
- if (!after.isSocket() || after.dev !== before.dev || after.ino !== before.ino) {
330
- throw new Error('The proxy socket path changed during startup.');
331
- }
332
- await plugins.fs.promises.unlink(this.socketPath);
333
- }
334
-
335
369
  private accept(socketArg: plugins.net.Socket): void {
336
370
  this.sockets.add(socketArg);
337
371
  const closeListeners: Array<() => void> = [];
@@ -348,6 +382,9 @@ export class AglProxyControlServer {
348
382
  });
349
383
  const connection: IAglProxyConnection = { signal: abort.signal, onClose: (listenerArg) => {
350
384
  if (socketArg.destroyed) listenerArg(); else closeListeners.push(listenerArg);
385
+ }, end: (reasonArg) => {
386
+ if (!socketArg.writable) { socketArg.destroy(); return; }
387
+ socketArg.end(JSON.stringify({ ended: reasonArg }) + '\n', () => socketArg.destroy());
351
388
  } };
352
389
  const chunks: Buffer[] = [];
353
390
  let bytes = 0;
@@ -5,6 +5,8 @@ import type {
5
5
  IAglProxyRefusalData,
6
6
  IAglProxyStatus,
7
7
  IReq_AglProxyCatalog,
8
+ IReq_AglProxyConfined,
9
+ IReq_AglProxyConfinedEnd,
8
10
  IReq_AglProxyCredential,
9
11
  IReq_AglProxyLease,
10
12
  IReq_AglProxyReclaim,
@@ -21,14 +23,17 @@ import type { TInferenceCredential, TInferenceProvider, TProxyCredentialDelivery
21
23
  * runs for its credential (`agl proxy credential <client>`) starts without loading the rest of AGL.
22
24
  *
23
25
  * The socket speaks one typed request per connection, one JSON line each way: the request as `TypedRequest` sends it
24
- * (`method`, `request`, `correlation`), and the router's answer with `response` or `error`. A lease and a reclaim keep
25
- * their connection; every other request closes it.
26
+ * (`method`, `request`, `correlation`), and the router's answer with `response` or `error`. A lease, a reclaim and a
27
+ * confined run keep their connection; every other request closes it.
26
28
  */
27
29
 
28
- /** The connection a lease lives as long as: closing it ends the lease, and `closed` settles once it is gone. */
30
+ /**
31
+ * The connection a lease lives as long as: closing it ends the lease, and `closed` settles once it is gone, with the
32
+ * proxy's reason when the proxy ended the lease itself (a confined run the operator ended), `undefined` otherwise.
33
+ */
29
34
  export interface IAglProxyLeaseConnection {
30
35
  close: () => void;
31
- closed: Promise<void>;
36
+ closed: Promise<string | undefined>;
32
37
  }
33
38
 
34
39
  /** One request per connection, as one JSON line each way; at most this many bytes. */
@@ -70,24 +75,28 @@ interface IWireAnswer {
70
75
  error?: { text?: unknown; data?: unknown } | null;
71
76
  }
72
77
 
73
- /** Sends one typed request on its own connection; `keepArg` receives the connection of an answered lease. */
78
+ /**
79
+ * Sends one typed request on its own connection; `keepArg` receives the connection of an answered lease, with what the
80
+ * proxy sent after its answer.
81
+ */
74
82
  const exchange = <TResponse>(socketPathArg: string, methodArg: string, requestArg: unknown, signalArg: AbortSignal,
75
- keepArg?: (socketArg: net.Socket) => void): Promise<TResponse> => new Promise((resolve, reject) => {
83
+ keepArg?: (socketArg: net.Socket, restArg: Buffer) => void): Promise<TResponse> => new Promise((resolve, reject) => {
76
84
  const signal = AbortSignal.any([signalArg, AbortSignal.timeout(requestTimeoutMs)]);
77
85
  if (signal.aborted) { reject(signal.reason); return; }
78
86
  const socket = net.createConnection(socketPathArg);
79
87
  const chunks: Buffer[] = [];
80
88
  let bytes = 0;
81
89
  let settled = false;
82
- const finish = (errorArg?: unknown, answerArg?: IWireAnswer): void => {
90
+ const finish = (errorArg?: unknown, answerArg?: IWireAnswer, restArg = Buffer.alloc(0)): void => {
83
91
  if (settled) return;
84
92
  settled = true;
85
93
  signal.removeEventListener('abort', abort);
94
+ socket.off('data', read);
86
95
  const failure = errorArg ?? (answerArg?.method !== methodArg
87
96
  ? new Error('The proxy answered another request.')
88
97
  : answerArg.error ? new AglProxyRefusedError(typeof answerArg.error.text === 'string' ? answerArg.error.text : 'The proxy refused.',
89
98
  answerArg.error.data) : undefined);
90
- if (!failure && keepArg) keepArg(socket);
99
+ if (!failure && keepArg) keepArg(socket, restArg);
91
100
  else socket.destroy();
92
101
  if (failure) reject(failure);
93
102
  else resolve(answerArg!.response as TResponse);
@@ -100,18 +109,24 @@ const exchange = <TResponse>(socketPathArg: string, methodArg: string, requestAr
100
109
  method: methodArg, request: requestArg, response: null, requestInstanceId: crypto.randomUUID(),
101
110
  correlation: { id: crypto.randomUUID(), phase: 'request' },
102
111
  }) + '\n'));
103
- socket.on('data', (chunkArg: Buffer) => {
112
+ function read(chunkArg: Buffer): void {
104
113
  bytes += chunkArg.length;
105
114
  if (bytes > proxyControlMaximumFrameBytes) { finish(new Error('The proxy response is too large.')); return; }
106
115
  chunks.push(chunkArg);
116
+ // A kept connection may carry the proxy's notice right behind the answer; any other answer is the frame's one line.
117
+ const frame = Buffer.concat(chunks, bytes);
118
+ const end = frame.indexOf(0x0a);
119
+ const rest = end < 0 ? Buffer.alloc(0) : frame.subarray(end + 1);
107
120
  let line: string | null;
108
- try { line = readProxyControlLine(chunks, bytes); } catch (errorArg) { finish(errorArg); return; }
121
+ try { line = readProxyControlLine([keepArg ? frame.subarray(0, end + 1) : frame], keepArg ? end + 1 : bytes); }
122
+ catch (errorArg) { finish(errorArg); return; }
109
123
  if (line === null) return;
110
124
  let answer: unknown;
111
125
  try { answer = JSON.parse(line); } catch { finish(new Error('The proxy response could not be decoded.')); return; }
112
126
  if (!answer || typeof answer !== 'object' || Array.isArray(answer)) { finish(new Error('The proxy response could not be decoded.')); return; }
113
- finish(undefined, answer as IWireAnswer);
114
- });
127
+ finish(undefined, answer as IWireAnswer, rest);
128
+ }
129
+ socket.on('data', read);
115
130
  // Nobody listening is a proxy that is not running; a connection that fails once open is a proxy that broke off.
116
131
  socket.on('error', () => finish(connected ? new Error('The proxy closed the connection before answering.') : new AglProxyUnavailableError()));
117
132
  socket.on('close', () => finish(new Error('The proxy closed the connection before answering.')));
@@ -157,9 +172,9 @@ export class AglProxyControlClient {
157
172
  */
158
173
  public async lease(policyArg: IInferenceRoutingPolicy, clientArg: string,
159
174
  signalArg: AbortSignal): Promise<IReq_AglProxyLease['response'] & IAglProxyLeaseConnection> {
160
- let held: net.Socket | undefined;
175
+ let held: { socket: net.Socket; rest: Buffer } | undefined;
161
176
  const response = await exchange<IReq_AglProxyLease['response']>(this.socketPath, 'agl.proxy.lease', { policy: policyArg, client: clientArg },
162
- signalArg, (socketArg) => { held = socketArg; });
177
+ signalArg, (socketArg, restArg) => { held = { socket: socketArg, rest: restArg }; });
163
178
  return { ...response, ...leaseConnection(held) };
164
179
  }
165
180
 
@@ -168,17 +183,54 @@ export class AglProxyControlClient {
168
183
  * connection. A refusal (`AglProxyRefusedError`) means the proxy no longer keeps it.
169
184
  */
170
185
  public async reclaim(tokenArg: string, signalArg: AbortSignal): Promise<IReq_AglProxyReclaim['response'] & IAglProxyLeaseConnection> {
171
- let held: net.Socket | undefined;
186
+ let held: { socket: net.Socket; rest: Buffer } | undefined;
172
187
  const response = await exchange<IReq_AglProxyReclaim['response']>(this.socketPath, 'agl.proxy.reclaim', { token: tokenArg },
173
- signalArg, (socketArg) => { held = socketArg; });
188
+ signalArg, (socketArg, restArg) => { held = { socket: socketArg, rest: restArg }; });
174
189
  return { ...response, ...leaseConnection(held) };
175
190
  }
191
+
192
+ /**
193
+ * A confined run's credential, the confined listener's socket and the origin to name with it; the credential lives as
194
+ * long as the returned connection (see `IReq_AglProxyConfined`).
195
+ */
196
+ public async confined(signalArg: AbortSignal): Promise<IReq_AglProxyConfined['response'] & IAglProxyLeaseConnection> {
197
+ let held: { socket: net.Socket; rest: Buffer } | undefined;
198
+ const response = await exchange<IReq_AglProxyConfined['response']>(this.socketPath, 'agl.proxy.confined', {},
199
+ signalArg, (socketArg, restArg) => { held = { socket: socketArg, rest: restArg }; });
200
+ return { ...response, ...leaseConnection(held) };
201
+ }
202
+
203
+ /** Ends the confined run `idArg` names at once; a refusal (`AglProxyRefusedError`) means the proxy serves no such run. */
204
+ public async endConfined(idArg: string, signalArg: AbortSignal): Promise<IReq_AglProxyConfinedEnd['response']> {
205
+ return exchange<IReq_AglProxyConfinedEnd['response']>(this.socketPath, 'agl.proxy.confined.end', { id: idArg }, signalArg);
206
+ }
176
207
  }
177
208
 
178
- const leaseConnection = (socketArg: net.Socket | undefined): IAglProxyLeaseConnection => {
179
- if (!socketArg) throw new Error('The proxy lease connection was not kept.');
180
- const socket = socketArg;
209
+ /**
210
+ * A kept connection: after its answer the proxy sends at most one line, `{"ended": reason}`, when it ends the lease
211
+ * itself, and closes the connection; `closed` then settles with that reason.
212
+ */
213
+ const leaseConnection = (heldArg: { socket: net.Socket; rest: Buffer } | undefined): IAglProxyLeaseConnection => {
214
+ if (!heldArg) throw new Error('The proxy lease connection was not kept.');
215
+ const socket = heldArg.socket;
216
+ let pending = heldArg.rest;
217
+ let ended: string | undefined;
218
+ const read = (chunkArg: Buffer): void => {
219
+ pending = Buffer.concat([pending, chunkArg]);
220
+ if (pending.length > proxyControlMaximumFrameBytes) { socket.destroy(); return; }
221
+ const end = pending.indexOf(0x0a);
222
+ if (end < 0) return;
223
+ try {
224
+ const notice: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(pending.subarray(0, end)));
225
+ if (notice && typeof notice === 'object' && 'ended' in notice && typeof notice.ended === 'string') ended = notice.ended;
226
+ } catch { /* Not a notice: the connection ends all the same, without a reason. */ }
227
+ socket.destroy();
228
+ };
181
229
  socket.on('error', () => socket.destroy());
182
- const closed = new Promise<void>((resolve) => { if (socket.destroyed) resolve(); else socket.once('close', () => resolve()); });
230
+ socket.on('data', read);
231
+ if (pending.length) read(Buffer.alloc(0));
232
+ const closed = new Promise<string | undefined>((resolve) => {
233
+ if (socket.destroyed) resolve(ended); else socket.once('close', () => resolve(ended));
234
+ });
183
235
  return { close: () => socket.destroy(), closed };
184
236
  };