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.
- package/changelog.md +43 -0
- package/dist_serve/bundle.js +3514 -3357
- package/dist_serve/bundle.js.map +2 -2
- package/dist_serve/bundle.js.third-party-notices.json +58 -32
- package/dist_serve/bundle.js.third-party-notices.txt +108 -33
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +64 -37
- package/dist_ts/classes.aglhome.d.ts +5 -0
- package/dist_ts/classes.aglhome.js +2 -1
- package/dist_ts/classes.authstore.js +5 -1
- package/dist_ts/classes.controller.d.ts +8 -0
- package/dist_ts/classes.controller.js +119 -22
- package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
- package/dist_ts/classes.controllereventdelivery.js +233 -0
- package/dist_ts/classes.flexmodels.public.js +4 -1
- package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
- package/dist_ts/classes.flexprojectionreader.js +29 -5
- package/dist_ts/classes.flexservice.d.ts +2 -1
- package/dist_ts/classes.flexservice.js +34 -6
- package/dist_ts/classes.flexstore.d.ts +41 -4
- package/dist_ts/classes.flexstore.js +266 -203
- package/dist_ts/classes.inferencegateway.d.ts +35 -1
- package/dist_ts/classes.inferencegateway.js +86 -26
- package/dist_ts/classes.inferenceservice.d.ts +4 -2
- package/dist_ts/classes.inferenceservice.js +5 -4
- package/dist_ts/classes.proxycontrol.d.ts +58 -4
- package/dist_ts/classes.proxycontrol.js +23 -44
- package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
- package/dist_ts/classes.proxycontrolclient.js +67 -17
- package/dist_ts/classes.proxydaemon.d.ts +22 -3
- package/dist_ts/classes.proxydaemon.js +73 -12
- package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
- package/dist_ts/classes.proxyleaseholder.js +66 -22
- package/dist_ts/classes.proxystore.d.ts +9 -0
- package/dist_ts/classes.proxystore.js +15 -7
- package/dist_ts/classes.ticketnotifier.js +4 -2
- package/dist_ts/classes.ticketstore.d.ts +50 -2
- package/dist_ts/classes.ticketstore.js +195 -78
- package/dist_ts/classes.workercallerbindings.d.ts +66 -0
- package/dist_ts/classes.workercallerbindings.js +211 -0
- package/dist_ts/classes.workercallers.d.ts +63 -2
- package/dist_ts/classes.workercallers.js +430 -65
- package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
- package/dist_ts/driver/classes.drivereventrelay.js +99 -6
- package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
- package/dist_ts/driver/functions.driverinstructions.js +1 -1
- package/dist_ts/driver/functions.drivertools.d.ts +11 -2
- package/dist_ts/driver/functions.drivertools.js +19 -9
- package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
- package/dist_ts/fleet/classes.boardpump.js +38 -20
- package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
- package/dist_ts/fleet/classes.ticketeffects.js +16 -10
- package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
- package/dist_ts/fleet/classes.workerlaunches.js +25 -19
- package/dist_ts/fleet/classes.workers.d.ts +21 -0
- package/dist_ts/fleet/classes.workers.js +110 -17
- package/dist_ts/fleet/functions.workervalidation.js +2 -2
- package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
- package/dist_ts/functions.flexprivatesnapshot.js +461 -0
- package/dist_ts/functions.inferencecli.d.ts +3 -2
- package/dist_ts/functions.inferencecli.js +67 -5
- package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
- package/dist_ts/functions.inferencecliarguments.js +29 -2
- package/dist_ts/functions.inferencelaunch.d.ts +24 -0
- package/dist_ts/functions.inferencelaunch.js +42 -2
- package/dist_ts/functions.mcptickettools.d.ts +3 -0
- package/dist_ts/functions.mcptickettools.js +2 -2
- package/dist_ts/functions.proxycontract.d.ts +11 -0
- package/dist_ts/functions.proxycontract.js +12 -1
- package/dist_ts/functions.proxysocket.d.ts +6 -0
- package/dist_ts/functions.proxysocket.js +46 -0
- package/dist_ts/functions.ticketcli.d.ts +1 -1
- package/dist_ts/functions.ticketcli.js +8 -4
- package/dist_ts/functions.ticketvalidation.d.ts +3 -0
- package/dist_ts/functions.ticketvalidation.js +7 -4
- package/dist_ts/functions.workerprocessproof.d.ts +6 -0
- package/dist_ts/functions.workerprocessproof.js +28 -4
- package/dist_ts/interfaces.flexipc.d.ts +21 -2
- package/dist_ts/interfaces.flexipc.js +33 -16
- package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
- package/dist_ts_interfaces/tickets.d.ts +37 -9
- package/dist_ts_interfaces/tickets.js +7 -1
- package/dist_ts_interfaces/workercallers.d.ts +43 -4
- package/package.json +7 -7
- package/readme.md +156 -23
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +63 -36
- package/ts/classes.aglhome.ts +6 -0
- package/ts/classes.authstore.ts +4 -0
- package/ts/classes.controller.ts +141 -32
- package/ts/classes.controllereventdelivery.ts +289 -0
- package/ts/classes.flexmodels.public.ts +10 -0
- package/ts/classes.flexprojectionreader.ts +30 -3
- package/ts/classes.flexservice.ts +33 -5
- package/ts/classes.flexstore.ts +381 -246
- package/ts/classes.inferencegateway.ts +104 -23
- package/ts/classes.inferenceservice.ts +6 -3
- package/ts/classes.proxycontrol.ts +76 -39
- package/ts/classes.proxycontrolclient.ts +72 -20
- package/ts/classes.proxydaemon.ts +85 -15
- package/ts/classes.proxyleaseholder.ts +84 -28
- package/ts/classes.proxystore.ts +16 -5
- package/ts/classes.ticketnotifier.ts +3 -1
- package/ts/classes.ticketstore.ts +201 -75
- package/ts/classes.workercallerbindings.ts +147 -0
- package/ts/classes.workercallers.ts +410 -59
- package/ts/driver/classes.drivereventrelay.ts +89 -4
- package/ts/driver/functions.driverinstructions.ts +1 -1
- package/ts/driver/functions.drivertools.ts +22 -8
- package/ts/fleet/classes.boardpump.ts +51 -19
- package/ts/fleet/classes.ticketeffects.ts +25 -11
- package/ts/fleet/classes.workerlaunches.ts +23 -15
- package/ts/fleet/classes.workers.ts +79 -17
- package/ts/fleet/functions.workervalidation.ts +1 -1
- package/ts/functions.flexprivatesnapshot.ts +601 -0
- package/ts/functions.inferencecli.ts +61 -7
- package/ts/functions.inferencecliarguments.ts +32 -1
- package/ts/functions.inferencelaunch.ts +49 -2
- package/ts/functions.mcptickettools.ts +1 -1
- package/ts/functions.proxycontract.ts +12 -0
- package/ts/functions.proxysocket.ts +39 -0
- package/ts/functions.ticketcli.ts +7 -3
- package/ts/functions.ticketvalidation.ts +7 -3
- package/ts/functions.workerprocessproof.ts +27 -4
- package/ts/interfaces.flexipc.ts +50 -21
- package/ts_interfaces/fleetworkers.ts +6 -0
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/requests.tickets.ts +9 -6
- package/ts_interfaces/tickets.ts +32 -1
- package/ts_interfaces/workercallers.ts +32 -4
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements.harnesscontrollerapp.ts +19 -8
- package/ts_web/elements.ticketboard.ts +16 -4
- 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
|
-
/**
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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<
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
};
|