agl 41.0.0 → 41.1.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 +18 -0
- package/dist_serve/bundle.js +881 -881
- package/dist_serve/bundle.js.map +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +6 -2
- package/dist_ts/classes.authmodels.d.ts +15 -0
- package/dist_ts/classes.authmodels.js +78 -7
- package/dist_ts/classes.authstore.js +23 -6
- package/dist_ts/classes.cli.js +8 -3
- package/dist_ts/classes.controller.d.ts +13 -0
- package/dist_ts/classes.controller.js +52 -15
- package/dist_ts/classes.controllermanagement.d.ts +53 -6
- package/dist_ts/classes.controllermanagement.js +180 -72
- package/dist_ts/classes.inferencegateway.d.ts +6 -6
- package/dist_ts/classes.inferencegateway.js +25 -13
- package/dist_ts/classes.mcpreexec.js +4 -4
- package/dist_ts/classes.processinspection.d.ts +3 -1
- package/dist_ts/classes.processinspection.js +4 -2
- package/dist_ts/classes.proxycontrol.d.ts +22 -7
- package/dist_ts/classes.proxycontrol.js +5 -3
- package/dist_ts/classes.proxycontrolclient.d.ts +4 -3
- package/dist_ts/classes.proxycontrolclient.js +7 -5
- package/dist_ts/classes.proxydaemon.d.ts +6 -4
- package/dist_ts/classes.proxydaemon.js +42 -17
- package/dist_ts/classes.proxyleaseholder.d.ts +11 -2
- package/dist_ts/classes.proxyleaseholder.js +25 -2
- package/dist_ts/classes.proxystore.d.ts +3 -2
- package/dist_ts/classes.proxystore.js +5 -2
- package/dist_ts/classes.upgradecoordinator.d.ts +2 -1
- package/dist_ts/classes.upgradecoordinator.js +13 -10
- package/dist_ts/classes.upgradetransaction.d.ts +6 -0
- package/dist_ts/classes.upgradetransaction.js +38 -13
- package/dist_ts/classes.workercallers.d.ts +2 -0
- package/dist_ts/classes.workercallers.js +11 -8
- package/dist_ts/functions.controllermanagementsurface.d.ts +37 -0
- package/dist_ts/functions.controllermanagementsurface.js +110 -0
- package/dist_ts/functions.inferencecli.d.ts +6 -3
- package/dist_ts/functions.inferencecli.js +9 -5
- package/dist_ts/functions.inferencecliarguments.d.ts +1 -1
- package/dist_ts/functions.inferencecliarguments.js +19 -7
- package/dist_ts/functions.inferencelaunch.d.ts +30 -9
- package/dist_ts/functions.inferencelaunch.js +49 -23
- package/dist_ts/functions.workerprocessproof.d.ts +12 -3
- package/dist_ts/functions.workerprocessproof.js +17 -6
- package/dist_ts/interfaces.auth.d.ts +14 -3
- package/dist_ts/interfaces.auth.js +4 -3
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/interfaces.d.ts +5 -0
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/management.d.ts +33 -0
- package/dist_ts_interfaces/management.js +35 -0
- package/package.json +2 -1
- package/readme.md +118 -31
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +5 -1
- package/ts/classes.authmodels.ts +74 -5
- package/ts/classes.authstore.ts +24 -4
- package/ts/classes.cli.ts +9 -2
- package/ts/classes.controller.ts +99 -60
- package/ts/classes.controllermanagement.ts +284 -107
- package/ts/classes.inferencegateway.ts +28 -17
- package/ts/classes.mcpreexec.ts +3 -3
- package/ts/classes.processinspection.ts +2 -1
- package/ts/classes.proxycontrol.ts +25 -9
- package/ts/classes.proxycontrolclient.ts +7 -4
- package/ts/classes.proxydaemon.ts +43 -17
- package/ts/classes.proxyleaseholder.ts +36 -7
- package/ts/classes.proxystore.ts +8 -3
- package/ts/classes.upgradecoordinator.ts +20 -11
- package/ts/classes.upgradetransaction.ts +53 -2
- package/ts/classes.workercallers.ts +12 -7
- package/ts/functions.controllermanagementsurface.ts +149 -0
- package/ts/functions.inferencecli.ts +15 -7
- package/ts/functions.inferencecliarguments.ts +16 -6
- package/ts/functions.inferencelaunch.ts +57 -23
- package/ts/functions.workerprocessproof.ts +17 -3
- package/ts/interfaces.auth.ts +14 -3
- package/ts_interfaces/index.ts +1 -0
- package/ts_interfaces/interfaces.ts +5 -0
- package/ts_interfaces/management.ts +52 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -75,6 +75,8 @@ type TReproof = { kind: 'rebound' } | { kind: 'refused'; reason: string } | { ki
|
|
|
75
75
|
|
|
76
76
|
export interface IControllerWorkerCallerOptions {
|
|
77
77
|
controllerGeneration: string;
|
|
78
|
+
/** The port this controller is configured to serve: a bridge an upgrade re-executed in place names it (`aglMcpReexecArguments`). */
|
|
79
|
+
controllerPort: number;
|
|
78
80
|
registry: ControllerMcpCallerRegistry;
|
|
79
81
|
readMaster(): Promise<IConfigurationMaster>;
|
|
80
82
|
captureCodexOrigin(launch: Extract<TFleetWorkerCallerLaunch, { harnessId: 'codex' }>): Promise<IWorkerCodexCallerOrigin>;
|
|
@@ -103,8 +105,10 @@ const pending = (): IWorkerCallerEnrollmentResponse => ({ callerPending: true, b
|
|
|
103
105
|
const transientRefusalMs = 5_000;
|
|
104
106
|
const maximumRefusals = 64;
|
|
105
107
|
const maximumLoggedReasons = 4096;
|
|
108
|
+
/** A message as a clause of a pending reason, without its final period: the log line ends the sentence. */
|
|
109
|
+
const clause = (messageArg: string): string => messageArg.replace(/\.+$/u, '');
|
|
106
110
|
const proofReason = (errorArg: unknown): string => errorArg instanceof WorkerCallerProofError
|
|
107
|
-
? errorArg.message : `a process could not be read (${(errorArg as NodeJS.ErrnoException).code ?? 'unknown'})`;
|
|
111
|
+
? clause(errorArg.message) : `a process could not be read (${(errorArg as NodeJS.ErrnoException).code ?? 'unknown'})`;
|
|
108
112
|
const assertIdentifier = (valueArg: string): void => {
|
|
109
113
|
if (typeof valueArg !== 'string' || !/^[A-Za-z0-9_.:-]{1,256}$/u.test(valueArg)) throw new Error('Invalid worker launch identifier.');
|
|
110
114
|
};
|
|
@@ -377,19 +381,20 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
377
381
|
private async reprove(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection): Promise<TReproof> {
|
|
378
382
|
const observe = this.options.observeClaudeJob;
|
|
379
383
|
if (!observe) return { kind: 'refused', reason: 'the process its launch is bound to is gone, and no fleet proves the job\'s next process' };
|
|
384
|
+
const port = this.options.controllerPort;
|
|
380
385
|
let bridge: IWorkerProcessSnapshot, command = recordArg.command!;
|
|
381
|
-
try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, command); } catch (errorArg) {
|
|
386
|
+
try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, command, port); } catch (errorArg) {
|
|
382
387
|
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
383
388
|
// An upgrade re-executes a bridge in place onto the build the controller runs now: it runs the command this
|
|
384
389
|
// controller resolves from its own configured `agl` entry, never one the bridge names.
|
|
385
390
|
let current: IWorkerBridgeCommand;
|
|
386
391
|
try { current = await this.currentCommand(); } catch (resolveArg) {
|
|
387
|
-
return { kind: 'refused', reason: `${proofReason(errorArg)} (the approved bridge command cannot be resolved now: ${resolveArg instanceof Error ? resolveArg.message : 'unknown failure'})` };
|
|
392
|
+
return { kind: 'refused', reason: `${proofReason(errorArg)} (the approved bridge command cannot be resolved now: ${resolveArg instanceof Error ? clause(resolveArg.message) : 'unknown failure'})` };
|
|
388
393
|
}
|
|
389
394
|
if (plugins.util.isDeepStrictEqual(current, command)) return { kind: 'refused', reason: proofReason(errorArg) };
|
|
390
|
-
try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, current); } catch (currentArg) {
|
|
395
|
+
try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, current, port); } catch (currentArg) {
|
|
391
396
|
if (!isWorkerCallerProofRefusal(currentArg)) throw currentArg;
|
|
392
|
-
return { kind: 'refused', reason: proofReason(errorArg) };
|
|
397
|
+
return { kind: 'refused', reason: `recorded bridge command: ${proofReason(errorArg)}; current bridge command: ${proofReason(currentArg)}` };
|
|
393
398
|
}
|
|
394
399
|
command = current;
|
|
395
400
|
}
|
|
@@ -433,7 +438,7 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
433
438
|
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
434
439
|
return refuse('the process of its Claude job is not the approved Claude Code executable', false);
|
|
435
440
|
}
|
|
436
|
-
try { await this.proof.verifyBridge(connectionArg, bridge.pid, command, parent); } catch (errorArg) {
|
|
441
|
+
try { await this.proof.verifyBridge(connectionArg, bridge.pid, command, parent, port); } catch (errorArg) {
|
|
437
442
|
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
438
443
|
return refuse('its bridge is not proved to be a direct child of the process of its Claude job', true);
|
|
439
444
|
}
|
|
@@ -678,7 +683,7 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
678
683
|
private async safeSnapshot(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection): Promise<IWorkerProcessSnapshot> {
|
|
679
684
|
if (recordArg.state !== 'bound' || !recordArg.command || !recordArg.parent || this.closed
|
|
680
685
|
|| (recordArg.codex && !await recordArg.codex.isCurrent())) throw new WorkerCallerProofError('Worker caller proof is unavailable.');
|
|
681
|
-
return this.proof.verifyBridge(connectionArg, bridgePidArg, recordArg.command, recordArg.parent);
|
|
686
|
+
return this.proof.verifyBridge(connectionArg, bridgePidArg, recordArg.command, recordArg.parent, this.options.controllerPort);
|
|
682
687
|
}
|
|
683
688
|
|
|
684
689
|
private async prove(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
controllerManagementMethods,
|
|
4
|
+
controllerManagementRequestMaximumBodyBytes,
|
|
5
|
+
controllerManagementTypedRequestPath,
|
|
6
|
+
} from '../ts_interfaces/index.js';
|
|
7
|
+
|
|
8
|
+
/** The direct peer addresses the management endpoint answers: this host's loopback, as Node reports it. */
|
|
9
|
+
const loopbackPeerAddresses = new Set(['127.0.0.1', '::ffff:127.0.0.1', '::1']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Headers a forwarding proxy adds, and the transport secret a trusted TLS proxy injects. A request that carries one
|
|
13
|
+
* came through a proxy, even one on this host whose connection is loopback, so it is not a local caller's.
|
|
14
|
+
*/
|
|
15
|
+
const forwardingHeaders = [
|
|
16
|
+
'forwarded',
|
|
17
|
+
'x-forwarded-for',
|
|
18
|
+
'x-forwarded-host',
|
|
19
|
+
'x-forwarded-proto',
|
|
20
|
+
'x-real-ip',
|
|
21
|
+
'x-harness-controller-proxy-secret',
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const envelopeKeys = new Set(['method', 'request', 'response', 'correlation', 'requestInstanceId']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The connection a management request arrived on, granted to its handler as `localData.peer`: one per request, so
|
|
28
|
+
* the handlers' per-peer operation and preflight budgets count it as a connection of its own.
|
|
29
|
+
*/
|
|
30
|
+
export interface IControllerManagementPeer {
|
|
31
|
+
id: string;
|
|
32
|
+
context: {
|
|
33
|
+
headers: Headers;
|
|
34
|
+
state: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const isLoopbackPeerAddress = (addressArg: string | undefined): boolean => (
|
|
39
|
+
addressArg !== undefined && loopbackPeerAddresses.has(addressArg)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Admits a request to the management endpoint before its body is read: from a loopback peer, a POST of
|
|
44
|
+
* `application/json` to the exact path, addressed to `127.0.0.1:<port>`, with no `Origin` (a browser always sends
|
|
45
|
+
* one with a POST) and no forwarding header. Anything else is refused; nothing about the request is logged.
|
|
46
|
+
*/
|
|
47
|
+
export const admitControllerManagementRequest = (
|
|
48
|
+
contextArg: plugins.smartserve.IRequestContext,
|
|
49
|
+
portArg: number,
|
|
50
|
+
): true | Response => {
|
|
51
|
+
if (!isLoopbackPeerAddress(contextArg.connectionInfo?.remoteAddr)) {
|
|
52
|
+
return new Response('Forbidden', { status: 403 });
|
|
53
|
+
}
|
|
54
|
+
const url = contextArg.url;
|
|
55
|
+
if (
|
|
56
|
+
url.pathname !== controllerManagementTypedRequestPath
|
|
57
|
+
|| url.search !== ''
|
|
58
|
+
|| url.hash !== ''
|
|
59
|
+
|| url.username !== ''
|
|
60
|
+
|| url.password !== ''
|
|
61
|
+
|| url.hostname !== '127.0.0.1'
|
|
62
|
+
|| url.port !== String(portArg)
|
|
63
|
+
|| contextArg.headers.get('host') !== `127.0.0.1:${portArg}`
|
|
64
|
+
) {
|
|
65
|
+
return new Response('Not Found', { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
contextArg.headers.has('origin')
|
|
69
|
+
|| forwardingHeaders.some((headerArg) => contextArg.headers.has(headerArg))
|
|
70
|
+
) {
|
|
71
|
+
return new Response('Forbidden', { status: 403 });
|
|
72
|
+
}
|
|
73
|
+
if (contextArg.method !== 'POST') {
|
|
74
|
+
return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } });
|
|
75
|
+
}
|
|
76
|
+
if (contextArg.headers.get('content-type') !== 'application/json') {
|
|
77
|
+
return new Response('Unsupported Media Type', { status: 415 });
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Admits one envelope: exactly the keys a TypedRequest client sends, a management method, a request object and a
|
|
84
|
+
* request-phase correlation. It grants the request its own peer and nothing else: every upgrade method still
|
|
85
|
+
* authorises itself against the upgrade token it is handed, as it does over the controller socket.
|
|
86
|
+
*/
|
|
87
|
+
export const admitControllerManagementTypedRequest = (
|
|
88
|
+
requestArg: plugins.typedrequestInterfaces.ITypedRequest,
|
|
89
|
+
contextArg: plugins.smartserve.IRequestContext,
|
|
90
|
+
): { trustedLocalData: { peer: IControllerManagementPeer } } | Response => {
|
|
91
|
+
const envelope: unknown = requestArg;
|
|
92
|
+
if (
|
|
93
|
+
typeof envelope !== 'object'
|
|
94
|
+
|| envelope === null
|
|
95
|
+
|| Array.isArray(envelope)
|
|
96
|
+
|| !Object.keys(envelope).every((keyArg) => envelopeKeys.has(keyArg))
|
|
97
|
+
) return new Response('Invalid request', { status: 400 });
|
|
98
|
+
const { method, request, correlation, requestInstanceId } = envelope as Record<string, unknown>;
|
|
99
|
+
if (
|
|
100
|
+
!controllerManagementMethods.some((methodArg) => methodArg === method)
|
|
101
|
+
|| typeof request !== 'object'
|
|
102
|
+
|| request === null
|
|
103
|
+
|| Array.isArray(request)
|
|
104
|
+
|| typeof correlation !== 'object'
|
|
105
|
+
|| correlation === null
|
|
106
|
+
|| (correlation as Record<string, unknown>).phase !== 'request'
|
|
107
|
+
|| typeof (correlation as Record<string, unknown>).id !== 'string'
|
|
108
|
+
|| typeof requestInstanceId !== 'string'
|
|
109
|
+
|| requestInstanceId.length === 0
|
|
110
|
+
) return new Response('Invalid request', { status: 400 });
|
|
111
|
+
return {
|
|
112
|
+
trustedLocalData: {
|
|
113
|
+
peer: {
|
|
114
|
+
id: `management:${plugins.crypto.randomUUID()}`,
|
|
115
|
+
context: { headers: contextArg.headers, state: {} },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The management endpoint as a surface of the controller's listener: it matches only the exact host and path, so
|
|
123
|
+
* every other request on the port keeps reaching the browser surface.
|
|
124
|
+
*/
|
|
125
|
+
export const createControllerManagementSurface = (optionsArg: {
|
|
126
|
+
port: number;
|
|
127
|
+
router: plugins.typedrequest.TypedRouter;
|
|
128
|
+
}): plugins.typedserver.ITypedServerSurface => ({
|
|
129
|
+
name: 'controller-management',
|
|
130
|
+
match: { hostnames: ['127.0.0.1'], pathPrefixes: [controllerManagementTypedRequestPath] },
|
|
131
|
+
httpTypedRouter: optionsArg.router,
|
|
132
|
+
typedRequestPath: controllerManagementTypedRequestPath,
|
|
133
|
+
typedRequestMaxBodyBytes: controllerManagementRequestMaximumBodyBytes,
|
|
134
|
+
includeBuiltinTypedHandlers: false,
|
|
135
|
+
serveDir: null,
|
|
136
|
+
spaFallback: false,
|
|
137
|
+
healthzEndpoint: false,
|
|
138
|
+
cors: false,
|
|
139
|
+
noCache: true,
|
|
140
|
+
requestAdmission: (contextArg) => admitControllerManagementRequest(contextArg, optionsArg.port),
|
|
141
|
+
typedRequestAdmission: admitControllerManagementTypedRequest,
|
|
142
|
+
securityHeaders: {
|
|
143
|
+
xFrameOptions: 'DENY',
|
|
144
|
+
xContentTypeOptions: true,
|
|
145
|
+
referrerPolicy: 'no-referrer',
|
|
146
|
+
crossOriginOpenerPolicy: 'same-origin',
|
|
147
|
+
crossOriginResourcePolicy: 'same-origin',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
@@ -19,12 +19,15 @@ type TAccountPlacementKey = 'lifecycle' | 'lapsesAt' | 'placementScore' | 'place
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* The status a running proxy answers with: one started from an AGL before 38.3.0 reports no accounts and no usage
|
|
22
|
-
* observations, one before 38.4.0 no lifecycle, placement or placement journal,
|
|
23
|
-
* its confined listener nor its runs
|
|
22
|
+
* observations, one before 38.4.0 no lifecycle, placement or placement journal, one before confined runs neither
|
|
23
|
+
* its confined listener nor its runs, and one before ChatGPT-pool confined runs no run's pool: its runs are all of the
|
|
24
|
+
* Claude pool.
|
|
24
25
|
*/
|
|
25
26
|
export type TReportedProxyStatus = Omit<IAglProxyStatus, 'accounts' | 'usageObservations' | 'nextPlacement'
|
|
26
27
|
| 'recentPlacements' | 'confinedSocketPath' | 'confinedRuns'>
|
|
27
|
-
& Partial<Pick<IAglProxyStatus, 'usageObservations' | 'nextPlacement' | 'recentPlacements' | 'confinedSocketPath'
|
|
28
|
+
& Partial<Pick<IAglProxyStatus, 'usageObservations' | 'nextPlacement' | 'recentPlacements' | 'confinedSocketPath'>>
|
|
29
|
+
& { confinedRuns?: Array<Omit<IAglProxyStatus['confinedRuns'][number], 'provider'>
|
|
30
|
+
& Partial<Pick<IAglProxyStatus['confinedRuns'][number], 'provider'>>> }
|
|
28
31
|
& { accounts?: Array<Omit<IAglProxyStatus['accounts'][number], TAccountPlacementKey>
|
|
29
32
|
& Partial<Pick<IAglProxyStatus['accounts'][number], TAccountPlacementKey>>> };
|
|
30
33
|
|
|
@@ -73,7 +76,8 @@ export const formatProxyStatus = (unitArg: string, statusArg: TReportedProxyStat
|
|
|
73
76
|
if (statusArg.confinedSocketPath !== undefined) {
|
|
74
77
|
lines.push(`Confined listener: ${statusArg.confinedSocketPath ?? 'none'}`);
|
|
75
78
|
for (const run of statusArg.confinedRuns ?? []) {
|
|
76
|
-
|
|
79
|
+
// An older proxy names no run's pool: it issues Claude-pool runs alone.
|
|
80
|
+
lines.push(`Confined run ${run.id}: ${run.provider === 'openai' ? 'ChatGPT pool' : 'Claude pool'}, issued ${run.createdAt}`
|
|
77
81
|
+ `${run.held ? '' : ', waiting for its launcher to take it back after a proxy restart'}`
|
|
78
82
|
+ ` (end it with \`agl proxy confined end ${run.id}\`)`);
|
|
79
83
|
}
|
|
@@ -213,13 +217,17 @@ const run = (optionsArg: IInferenceCliArguments): Promise<void> => withSignals(a
|
|
|
213
217
|
const confinedRun = (optionsArg: IInferenceCliArguments): Promise<void> => withSignals(async (signalArg) => {
|
|
214
218
|
await preflightAGLHomeMigration();
|
|
215
219
|
const paths = resolveAGLHomePaths();
|
|
216
|
-
const run = await AglProxyConfinedHolder.take({ socketPath: paths.proxySocket
|
|
220
|
+
const run = await AglProxyConfinedHolder.take({ socketPath: paths.proxySocket, provider: optionsArg.provider ?? 'anthropic' },
|
|
221
|
+
AbortSignal.any([signalArg, AbortSignal.timeout(90_000)]));
|
|
217
222
|
let lost: AglProxyLeaseLostError | undefined;
|
|
218
223
|
try {
|
|
219
224
|
const launch = confinedDockerLaunch({ args: optionsArg.args, socketPath: run.confinedSocketPath, baseUrl: run.baseUrl,
|
|
220
|
-
token: run.token, environment: process.env
|
|
225
|
+
token: run.token, environment: process.env,
|
|
226
|
+
pool: run.provider === 'anthropic' ? { provider: 'anthropic' } : { provider: 'openai', models: run.models } });
|
|
227
|
+
// A Claude-pool run says what it said before ChatGPT-pool runs; a ChatGPT-pool run says so.
|
|
221
228
|
process.stderr.write(`AGL proxy confined run ${run.id}: ${plugins.path.dirname(run.confinedSocketPath)} is mounted read-only `
|
|
222
|
-
+ `at ${aglProxyConfinedMountPoint}
|
|
229
|
+
+ `at ${aglProxyConfinedMountPoint}${run.provider === 'openai' ? ', served from the ChatGPT pool' : ''}; `
|
|
230
|
+
+ `end it at once with \`agl proxy confined end ${run.id}\`.\n`);
|
|
223
231
|
signalArg.throwIfAborted();
|
|
224
232
|
const child = plugins.childProcess.spawn(launch.command, launch.args, { env: launch.environment, stdio: 'inherit', shell: false });
|
|
225
233
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -26,7 +26,7 @@ export const inferenceCliHelp = `Usage:
|
|
|
26
26
|
agl proxy status [--json]
|
|
27
27
|
agl proxy credential <client> [--provider openai|anthropic] [--delivery file|none] [--write <path>] [--rotate] | --version
|
|
28
28
|
agl proxy run codex|claude|opencode [--provider openai|anthropic] [--account <id>] [--plan <name>] [--model <id>] -- [harness arguments]
|
|
29
|
-
agl proxy confined run -- docker run [docker options] <image> [command]
|
|
29
|
+
agl proxy confined run [--provider anthropic|openai] -- docker run [docker options] <image> [command]
|
|
30
30
|
agl proxy confined end <run id>
|
|
31
31
|
|
|
32
32
|
The proxy runs as the ${aglProxyUnitName} user unit (serve runs it in a terminal instead).
|
|
@@ -40,12 +40,15 @@ client starts on Claude accounts, every other client on ChatGPT accounts; --prov
|
|
|
40
40
|
Run gives one child a private credential that ends when the child exits; run claude uses Claude accounts
|
|
41
41
|
unless --provider openai asks for ChatGPT models behind Claude Code.
|
|
42
42
|
confined run starts Claude Code's container on a credential of its own, accepted only on the proxy's confined
|
|
43
|
-
listener: a Unix socket whose directory the run mounts read-only at ${aglProxyConfinedMountPoint}, served
|
|
44
|
-
three
|
|
45
|
-
|
|
43
|
+
listener: a Unix socket whose directory the run mounts read-only at ${aglProxyConfinedMountPoint}, served Claude Code's
|
|
44
|
+
three endpoints of the run's pool alone: Claude accounts unless --provider openai asks for the ChatGPT pool, whose
|
|
45
|
+
ChatGPT accounts answer it translated, as for run claude --provider openai. It adds the mount and Claude Code's settings
|
|
46
|
+
(ANTHROPIC_UNIX_SOCKET, ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and on the ChatGPT pool the model variables run claude
|
|
47
|
+
--provider openai sets) right after docker run, prints the run's id on standard error, and ends the credential when the
|
|
46
48
|
container exits. The socket is the proxy user's (0600): run the container as that uid (--user), or as root under
|
|
47
49
|
rootless Docker. Root of a rootful daemon connects as well while it keeps CAP_DAC_OVERRIDE (--cap-drop ALL takes it).
|
|
48
|
-
confined end ends a run's credential at once, and its launcher stops the container; agl proxy status lists the runs
|
|
50
|
+
confined end ends a run's credential at once, and its launcher stops the container; agl proxy status lists the runs
|
|
51
|
+
with their pools.
|
|
49
52
|
Repeat --account or --plan to restrict automatic routing. Only accounts the account service holds are eligible.
|
|
50
53
|
ChatGPT pools: /openai/v1/responses, /anthropic/v1/messages (translated), and each prefix's /models.
|
|
51
54
|
Claude pools: /anthropic/v1/messages, /anthropic/v1/messages/count_tokens and /anthropic/v1/models, sent on as Claude Code sent them.
|
|
@@ -124,7 +127,14 @@ export const parseInferenceCliArguments = (argvArg: readonly string[]): IInferen
|
|
|
124
127
|
result.confinedId = id;
|
|
125
128
|
return result;
|
|
126
129
|
}
|
|
127
|
-
if (action !== 'run'
|
|
130
|
+
if (action !== 'run') throw new Error(inferenceCliHelp);
|
|
131
|
+
if (argv[0] === '--provider') {
|
|
132
|
+
argv.shift();
|
|
133
|
+
const value = argv.shift();
|
|
134
|
+
if (!inferenceProviders.includes(value as TInferenceProvider)) throw new Error('--provider is openai or anthropic.');
|
|
135
|
+
result.provider = value as TInferenceProvider;
|
|
136
|
+
}
|
|
137
|
+
if (argv.shift() !== '--' || !argv.length) throw new Error(inferenceCliHelp);
|
|
128
138
|
result.confinedAction = 'run';
|
|
129
139
|
result.args = argv;
|
|
130
140
|
return result;
|
|
@@ -37,10 +37,41 @@ const claudeNativeLaunch = (optionsArg: IInferenceLaunchOptions & { catalog: { p
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Claude Code on a ChatGPT pool: the proxy's Anthropic Messages endpoint
|
|
41
|
-
* for every model alias, the context window the catalog states for it, and
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* The variables that put Claude Code on a ChatGPT pool: the proxy's Anthropic Messages endpoint and credential, the
|
|
41
|
+
* pool's default model for every model alias, the context window the catalog states for it, and nothing beyond its model
|
|
42
|
+
* requests: no experimental betas, no nonessential traffic, no attribution. `agl proxy run claude --provider openai` and a
|
|
43
|
+
* ChatGPT-pool confined run give Claude Code these alike.
|
|
44
|
+
*/
|
|
45
|
+
export const claudeChatGptEnvironment = (inputArg: {
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
token: string;
|
|
48
|
+
models: readonly TInferenceModel[];
|
|
49
|
+
}): Record<string, string> => {
|
|
50
|
+
const model = inputArg.models.find((entryArg) => entryArg.isDefault) ?? inputArg.models[0];
|
|
51
|
+
if (!model) throw new Error('The ChatGPT pool offers no model Claude Code can run on.');
|
|
52
|
+
return {
|
|
53
|
+
ANTHROPIC_BASE_URL: inputArg.baseUrl,
|
|
54
|
+
ANTHROPIC_AUTH_TOKEN: inputArg.token,
|
|
55
|
+
ANTHROPIC_MODEL: model.modelId,
|
|
56
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: model.modelId,
|
|
57
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: model.modelId,
|
|
58
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: model.modelId,
|
|
59
|
+
...(model.contextWindow === undefined ? {} : { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(model.contextWindow) }),
|
|
60
|
+
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1',
|
|
61
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
62
|
+
CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Every variable `claudeChatGptEnvironment` may set, the context window's included. */
|
|
67
|
+
const claudeChatGptVariables: readonly string[] = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL',
|
|
68
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
69
|
+
'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
|
70
|
+
'CLAUDE_CODE_ATTRIBUTION_HEADER'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Claude Code on a ChatGPT pool (`claudeChatGptEnvironment`), with the pool's models as the whole `/model` picker. Its
|
|
74
|
+
* configuration lives in its own directory, never the host's, so the host's `settings.json` cannot route it elsewhere.
|
|
44
75
|
*/
|
|
45
76
|
export const claudeChatGptLaunch = (inputArg: {
|
|
46
77
|
baseUrl: string;
|
|
@@ -48,21 +79,10 @@ export const claudeChatGptLaunch = (inputArg: {
|
|
|
48
79
|
configDirectory: string;
|
|
49
80
|
models: readonly TInferenceModel[];
|
|
50
81
|
}): { environment: Record<string, string>; args: string[] } => {
|
|
51
|
-
const model = inputArg.models.find((entryArg) => entryArg.isDefault) ?? inputArg.models[0];
|
|
52
|
-
if (!model) throw new Error('The ChatGPT pool offers no model Claude Code can run on.');
|
|
53
82
|
return {
|
|
54
83
|
environment: {
|
|
55
84
|
CLAUDE_CONFIG_DIR: inputArg.configDirectory,
|
|
56
|
-
|
|
57
|
-
ANTHROPIC_AUTH_TOKEN: inputArg.token,
|
|
58
|
-
ANTHROPIC_MODEL: model.modelId,
|
|
59
|
-
ANTHROPIC_DEFAULT_OPUS_MODEL: model.modelId,
|
|
60
|
-
ANTHROPIC_DEFAULT_SONNET_MODEL: model.modelId,
|
|
61
|
-
ANTHROPIC_DEFAULT_HAIKU_MODEL: model.modelId,
|
|
62
|
-
...(model.contextWindow === undefined ? {} : { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(model.contextWindow) }),
|
|
63
|
-
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1',
|
|
64
|
-
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
65
|
-
CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
|
|
85
|
+
...claudeChatGptEnvironment(inputArg),
|
|
66
86
|
},
|
|
67
87
|
args: ['--settings', JSON.stringify({
|
|
68
88
|
modelPicker: {
|
|
@@ -140,16 +160,23 @@ export const inferenceHarnessLaunch = (optionsArg: IInferenceLaunchOptions): { a
|
|
|
140
160
|
return { environment, args: args[0] !== undefined && openCodeCommands.has(args[0]) ? args : [...selection, ...args] };
|
|
141
161
|
};
|
|
142
162
|
|
|
163
|
+
/** A confined run's pool, as its launcher configures Claude Code for it: a ChatGPT-pool run with the pool's models. */
|
|
164
|
+
export type TConfinedLaunchPool = { provider: 'anthropic' } | { provider: 'openai'; models: readonly TInferenceModel[] };
|
|
165
|
+
|
|
143
166
|
/**
|
|
144
167
|
* A confined run's `docker run`: the given command, with the confined listener's directory mounted read-only at
|
|
145
168
|
* `aglProxyConfinedMountPoint` and Claude Code's settings for it added right after `run`, before the launcher's own
|
|
146
169
|
* options and image. Claude Code inside reaches the proxy through the socket alone (`ANTHROPIC_UNIX_SOCKET`, which
|
|
147
170
|
* Claude Code sends its API requests through as plain HTTP to `ANTHROPIC_BASE_URL`'s path), on the run's credential
|
|
148
|
-
* (`ANTHROPIC_AUTH_TOKEN`),
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
171
|
+
* (`ANTHROPIC_AUTH_TOKEN`), and sends nothing beyond its model requests. On the Claude pool it reads the pool's models
|
|
172
|
+
* from the proxy; on the ChatGPT pool it gets the variables `agl proxy run claude --provider openai` gives it
|
|
173
|
+
* (`claudeChatGptEnvironment`: the pool's default model for every alias, its context window), but neither that command's
|
|
174
|
+
* configuration directory, which lies in the AGL home, nor its `/model` picker, an option of the container's command
|
|
175
|
+
* that the launcher does not touch. The container gets each variable by name from the docker command's environment, so
|
|
176
|
+
* the credential never appears on a command line; that environment drops every variable that would give Claude Code
|
|
177
|
+
* another login or route, and on the ChatGPT pool every model variable it sets, so `-e NAME` of one of them passes
|
|
178
|
+
* nothing. The mount is the socket's own directory, which holds nothing else and survives a proxy restart, unlike a
|
|
179
|
+
* mount of the socket file. Only `docker run` and `docker container run` are taken.
|
|
153
180
|
*/
|
|
154
181
|
export const confinedDockerLaunch = (optionsArg: {
|
|
155
182
|
args: readonly string[];
|
|
@@ -159,6 +186,7 @@ export const confinedDockerLaunch = (optionsArg: {
|
|
|
159
186
|
baseUrl: string;
|
|
160
187
|
token: string;
|
|
161
188
|
environment: NodeJS.ProcessEnv;
|
|
189
|
+
pool: TConfinedLaunchPool;
|
|
162
190
|
}): { command: string; args: string[]; environment: NodeJS.ProcessEnv } => {
|
|
163
191
|
const [command, ...args] = optionsArg.args;
|
|
164
192
|
const runAt = args[0] === 'run' ? 1 : args[0] === 'container' && args[1] === 'run' ? 2 : 0;
|
|
@@ -168,13 +196,19 @@ export const confinedDockerLaunch = (optionsArg: {
|
|
|
168
196
|
const directory = path.dirname(optionsArg.socketPath);
|
|
169
197
|
// `--mount` separates its fields with commas and reads quotes; a directory naming either cannot be passed safely.
|
|
170
198
|
if (/[,"'\n]/u.test(directory)) throw new Error(`The confined listener's directory ${directory} cannot be mounted: it holds a comma, quote or line break.`);
|
|
171
|
-
const
|
|
199
|
+
const socket = `${aglProxyConfinedMountPoint}/${path.basename(optionsArg.socketPath)}`;
|
|
200
|
+
const variables: Record<string, string> = optionsArg.pool.provider === 'anthropic' ? {
|
|
172
201
|
...claudeProxyEnvironment(optionsArg.baseUrl),
|
|
173
|
-
ANTHROPIC_UNIX_SOCKET:
|
|
202
|
+
ANTHROPIC_UNIX_SOCKET: socket,
|
|
174
203
|
ANTHROPIC_AUTH_TOKEN: optionsArg.token,
|
|
204
|
+
} : {
|
|
205
|
+
...claudeChatGptEnvironment({ baseUrl: `${optionsArg.baseUrl}/anthropic`, token: optionsArg.token,
|
|
206
|
+
models: optionsArg.pool.models }),
|
|
207
|
+
ANTHROPIC_UNIX_SOCKET: socket,
|
|
175
208
|
};
|
|
176
209
|
const environment = { ...optionsArg.environment };
|
|
177
210
|
for (const name of claudeProxyReservedVariables) delete environment[name];
|
|
211
|
+
if (optionsArg.pool.provider === 'openai') for (const name of claudeChatGptVariables) delete environment[name];
|
|
178
212
|
return {
|
|
179
213
|
command,
|
|
180
214
|
args: [...args.slice(0, runAt),
|
|
@@ -142,15 +142,26 @@ const processOwnsLoopbackConnection = async (connectionArg: IWorkerCallerConnect
|
|
|
142
142
|
return matches.length === 1 && await processOwnsInode(pidArg, matches[0][9]);
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The arguments after its `cli.js` entry that an upgrade re-executes an `agl mcp` bridge of the controller on
|
|
147
|
+
* `controllerPortArg` with (`AglMcpReexecCoordinator`): the port pins the next image to its handover record's.
|
|
148
|
+
* A bridge proof accepts exactly these besides the approved command's own.
|
|
149
|
+
*/
|
|
150
|
+
export const aglMcpReexecArguments = (controllerPortArg: number): string[] => ['mcp', '--port', String(controllerPortArg)];
|
|
151
|
+
|
|
145
152
|
/**
|
|
146
153
|
* The bridge process `bridgePidArg`, proved to hold the caller's end of the direct loopback connection and to run the
|
|
147
154
|
* approved command, whatever its parent. The command is the one recorded when the launch was prepared: an upgrade
|
|
148
|
-
* installs AGL under another path, and a command whose files are gone is refused as such.
|
|
155
|
+
* installs AGL under another path, and a command whose files are gone is refused as such. Its arguments are the
|
|
156
|
+
* approved ones or, once an upgrade re-executed the bridge in place, `aglMcpReexecArguments` for `controllerPortArg`,
|
|
157
|
+
* the port this controller is configured with, never one the bridge names: the connection proof already shows that
|
|
158
|
+
* the bridge talks to this controller.
|
|
149
159
|
*/
|
|
150
160
|
export const readWorkerBridge = async (
|
|
151
161
|
connectionArg: IWorkerCallerConnection | undefined,
|
|
152
162
|
bridgePidArg: number,
|
|
153
163
|
commandArg: IWorkerBridgeCommand,
|
|
164
|
+
controllerPortArg: number,
|
|
154
165
|
): Promise<IWorkerProcessSnapshot> => {
|
|
155
166
|
for (const file of [commandArg.executable, commandArg.entry]) {
|
|
156
167
|
let resolved: string;
|
|
@@ -165,8 +176,10 @@ export const readWorkerBridge = async (
|
|
|
165
176
|
}
|
|
166
177
|
const bridge = await readWorkerProcessSnapshot(bridgePidArg);
|
|
167
178
|
const entry = await plugins.fs.promises.realpath(bridge.arguments[1] ?? '');
|
|
179
|
+
const argumentsTail = bridge.arguments.slice(2);
|
|
168
180
|
if (bridge.executable !== commandArg.executable || entry !== commandArg.entry
|
|
169
|
-
|| !plugins.util.isDeepStrictEqual(
|
|
181
|
+
|| (!plugins.util.isDeepStrictEqual(argumentsTail, commandArg.arguments)
|
|
182
|
+
&& !plugins.util.isDeepStrictEqual(argumentsTail, aglMcpReexecArguments(controllerPortArg)))) {
|
|
170
183
|
throw new WorkerCallerProofError('Worker bridge does not match the approved launch.');
|
|
171
184
|
}
|
|
172
185
|
return bridge;
|
|
@@ -177,8 +190,9 @@ export const verifyWorkerBridge = async (
|
|
|
177
190
|
bridgePidArg: number,
|
|
178
191
|
commandArg: IWorkerBridgeCommand,
|
|
179
192
|
parentArg: IWorkerProcessSnapshot,
|
|
193
|
+
controllerPortArg: number,
|
|
180
194
|
): Promise<IWorkerProcessSnapshot> => {
|
|
181
|
-
const bridge = await readWorkerBridge(connectionArg, bridgePidArg, commandArg);
|
|
195
|
+
const bridge = await readWorkerBridge(connectionArg, bridgePidArg, commandArg, controllerPortArg);
|
|
182
196
|
if (bridge.parentPid !== parentArg.pid) throw new WorkerCallerProofError('Worker bridge does not match the approved launch.');
|
|
183
197
|
if (!workerSnapshotsEqual(parentArg, await readWorkerProcessSnapshot(parentArg.pid))
|
|
184
198
|
|| !workerSnapshotsEqual(bridge, await readWorkerProcessSnapshot(bridge.pid))) {
|
package/ts/interfaces.auth.ts
CHANGED
|
@@ -7,9 +7,10 @@ export type TControllerAuthLifecycleState = 'setupRequired' | 'ready';
|
|
|
7
7
|
export type TWebAuthnCeremonyKind = 'setup' | 'authentication';
|
|
8
8
|
export type TWebAuthnCeremonyState = 'pending' | 'consumed';
|
|
9
9
|
/**
|
|
10
|
-
* Single source of the audit
|
|
10
|
+
* Single source of the audit types this release writes: the persisted-document
|
|
11
11
|
* assertion consumes this list at runtime, so a type added here is
|
|
12
|
-
* automatically storable — the two can no longer
|
|
12
|
+
* automatically storable with its type-specific checks — the two can no longer
|
|
13
|
+
* drift apart. Other well-formed types are a later release's and still read.
|
|
13
14
|
*/
|
|
14
15
|
export const controllerAuditEventTypes = [
|
|
15
16
|
'controller.started',
|
|
@@ -249,11 +250,16 @@ export type TControllerAuditSessionId =
|
|
|
249
250
|
| IControllerRuntimeId
|
|
250
251
|
| plugins.browserRuntime.IBrowserClaudeSessionId;
|
|
251
252
|
|
|
253
|
+
/**
|
|
254
|
+
* A stored audit event. The audit log is shared with later releases, so a stored `type` is any audit type, also one
|
|
255
|
+
* this release does not know (a later release's, read back after a rollback). This release writes only its own types
|
|
256
|
+
* (`IRecordAuditEventInput`).
|
|
257
|
+
*/
|
|
252
258
|
export interface IControllerAuditEvent {
|
|
253
259
|
id: string;
|
|
254
260
|
controllerId: string;
|
|
255
261
|
timestamp: Date;
|
|
256
|
-
type:
|
|
262
|
+
type: string;
|
|
257
263
|
outcome: TControllerAuditOutcome;
|
|
258
264
|
operationId?: string;
|
|
259
265
|
peerId?: string;
|
|
@@ -263,6 +269,11 @@ export interface IControllerAuditEvent {
|
|
|
263
269
|
/** The sender label an agent message declared (`session.message`). */
|
|
264
270
|
senderLabel?: string;
|
|
265
271
|
localCli?: IConfigurationLocalCliActor;
|
|
272
|
+
/**
|
|
273
|
+
* What a later release's type records beyond the envelope (bounded plain JSON). This release never writes or
|
|
274
|
+
* interprets it; it is declared so a rolled-back controller still reads and prunes such events.
|
|
275
|
+
*/
|
|
276
|
+
details?: Record<string, unknown>;
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
export interface IControllerTempPasswordDocument {
|
package/ts_interfaces/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './functions.browserpaintcopy.js';
|
|
|
9
9
|
export * from './devtools.js';
|
|
10
10
|
export * from './requests.js';
|
|
11
11
|
export * from './mcprequests.js';
|
|
12
|
+
export * from './management.js';
|
|
12
13
|
export * from './codex.js';
|
|
13
14
|
export * from './mcpworkspacerequests.js';
|
|
14
15
|
export * from './mcpjobrequests.js';
|
|
@@ -325,6 +325,11 @@ export interface IControllerStatus {
|
|
|
325
325
|
packageVersion: string;
|
|
326
326
|
protocolVersion: typeof controllerProtocolVersion;
|
|
327
327
|
upgradeManagementVersion: number;
|
|
328
|
+
/**
|
|
329
|
+
* The protocol of the controller's loopback management endpoint (`controllerManagementProtocolVersion`); absent
|
|
330
|
+
* from a controller older than the endpoint, which is reached over its socket.
|
|
331
|
+
*/
|
|
332
|
+
managementProtocolVersion?: number;
|
|
328
333
|
controllerPid: number;
|
|
329
334
|
processGroupId: number;
|
|
330
335
|
processFingerprint: string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IReq_ControllerStatus } from './requests.status.js';
|
|
2
|
+
import type {
|
|
3
|
+
IReq_ControllerUpgradeFinalize,
|
|
4
|
+
IReq_ControllerUpgradeLaunch,
|
|
5
|
+
IReq_ControllerUpgradePrepareBegin,
|
|
6
|
+
} from './requests.upgrade.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The controller's loopback management endpoint, on the controller's own port: one TypedRequest envelope
|
|
10
|
+
* (`method`, `request`, `response`, `correlation`, `requestInstanceId`) per HTTP POST of `application/json`, with
|
|
11
|
+
* `Host: 127.0.0.1:<port>`. The local CLI and the upgrade worker read the status and drive an upgrade through it,
|
|
12
|
+
* so an upgrade worker of one release can finalize the controller of the next whatever transport the browser
|
|
13
|
+
* speaks. Every later release keeps this path, these methods, their request and response shapes and the envelope
|
|
14
|
+
* (`test/test.controllermanagementwire.node.ts` pins the bytes an AGL 41 client sends and what it accepts).
|
|
15
|
+
*/
|
|
16
|
+
export const controllerManagementTypedRequestPath = '/management/typedrequest' as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The protocol of this loopback management endpoint: its path, methods, envelope and their request and response
|
|
20
|
+
* shapes. 1 is the endpoint as its first release ships it; only an incompatible change to that contract raises it.
|
|
21
|
+
* A release declares it twice: as `controllerManagementProtocolVersion` in its `package.json`, which a client reads
|
|
22
|
+
* before the release's controller runs, and as `managementProtocolVersion` in that controller's status. A client
|
|
23
|
+
* reaches a controller over this endpoint only when it declares 1 or more, and over the controller socket otherwise
|
|
24
|
+
* (an older release has no endpoint).
|
|
25
|
+
*
|
|
26
|
+
* It is not `controllerUpgradeManagementVersion` (2), the upgrade contract: every upgrade worker requires exactly
|
|
27
|
+
* that number in its target's `package.json` and status, and upgrade transactions record it, so it cannot move
|
|
28
|
+
* without breaking the online upgrade of every earlier release. This number says only how the management methods
|
|
29
|
+
* are reached.
|
|
30
|
+
*/
|
|
31
|
+
export const controllerManagementProtocolVersion = 1 as const;
|
|
32
|
+
|
|
33
|
+
/** The methods the endpoint answers; every other method is refused before it is routed. */
|
|
34
|
+
export const controllerManagementMethods = [
|
|
35
|
+
'controller.status',
|
|
36
|
+
'controller.upgrade.launch',
|
|
37
|
+
'controller.upgrade.prepare.begin',
|
|
38
|
+
'controller.upgrade.finalize',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type TControllerManagementMethod = typeof controllerManagementMethods[number];
|
|
42
|
+
|
|
43
|
+
export type TControllerManagementTypedRequest =
|
|
44
|
+
| IReq_ControllerStatus
|
|
45
|
+
| IReq_ControllerUpgradeLaunch
|
|
46
|
+
| IReq_ControllerUpgradePrepareBegin
|
|
47
|
+
| IReq_ControllerUpgradeFinalize;
|
|
48
|
+
|
|
49
|
+
/** A management request carries a token, a version and a mode at most. */
|
|
50
|
+
export const controllerManagementRequestMaximumBodyBytes = 16 * 1024;
|
|
51
|
+
/** The status document with every harness profile and upgrade blocker it may report. */
|
|
52
|
+
export const controllerManagementResponseMaximumBytes = 1024 * 1024;
|