agl 40.5.0 → 40.6.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 +16 -0
- package/dist_serve/bundle.js +598 -598
- package/dist_serve/bundle.js.map +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +2 -2
- 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.js +11 -2
- 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.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/fleet/classes.workerlaunches.d.ts +12 -5
- package/dist_ts/fleet/classes.workerlaunches.js +25 -19
- package/dist_ts/fleet/classes.workers.d.ts +17 -0
- package/dist_ts/fleet/classes.workers.js +74 -1
- 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.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.workerprocessproof.d.ts +6 -0
- package/dist_ts/functions.workerprocessproof.js +28 -4
- package/dist_ts_authswitch/authority-contract.d.ts +30 -5
- package/dist_ts_authswitch/authority-contract.js +7 -1
- package/dist_ts_authswitch/classes.authoritycli.js +6 -2
- package/dist_ts_authswitch/classes.authorityclient.js +15 -5
- package/dist_ts_authswitch/classes.claudenative.d.ts +7 -1
- package/dist_ts_authswitch/classes.claudenative.js +21 -6
- package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
- package/dist_ts_authswitch/classes.claudesignin.js +68 -22
- package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
- package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
- package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
- package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/workercallers.d.ts +43 -4
- package/package.json +1 -1
- package/readme.md +103 -14
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +2 -2
- package/ts/classes.aglhome.ts +6 -0
- package/ts/classes.authstore.ts +4 -0
- package/ts/classes.controller.ts +10 -1
- 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.workercallerbindings.ts +147 -0
- package/ts/classes.workercallers.ts +410 -59
- package/ts/fleet/classes.workerlaunches.ts +23 -15
- package/ts/fleet/classes.workers.ts +51 -1
- package/ts/functions.inferencecli.ts +61 -7
- package/ts/functions.inferencecliarguments.ts +32 -1
- package/ts/functions.inferencelaunch.ts +49 -2
- package/ts/functions.proxycontract.ts +12 -0
- package/ts/functions.proxysocket.ts +39 -0
- package/ts/functions.workerprocessproof.ts +27 -4
- package/ts_authswitch/authority-contract.ts +34 -5
- package/ts_authswitch/classes.authoritycli.ts +5 -1
- package/ts_authswitch/classes.authorityclient.ts +25 -9
- package/ts_authswitch/classes.claudenative.ts +23 -7
- package/ts_authswitch/classes.claudesignin.ts +70 -23
- package/ts_authswitch/classes.codexmanaged.ts +30 -16
- package/ts_authswitch/classes.harnessprocesses.ts +39 -8
- package/ts_authswitch/native-contracts.md +1 -1
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/workercallers.ts +32 -4
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/accounts.plugins.ts +5 -1
- package/ts_web/classes.accountsreadmodel.ts +4 -1
|
@@ -4,14 +4,19 @@ import {
|
|
|
4
4
|
controllerMcpCallerCredentialEnvironmentVariable,
|
|
5
5
|
type IFleetWorkerCallerHooks, type TFleetWorkerCallerLaunch, type TFleetWorkerCallerBinding,
|
|
6
6
|
type IWorkerCallerReadiness, type TWorkerCallerEnrollment, type IWorkerCallerEnrollmentResponse,
|
|
7
|
-
type IConfigurationMaster, type IControllerMcpCaller, type IControllerMcpWireCaller,
|
|
7
|
+
type IConfigurationMaster, type IConfigurationMcpStdioServer, type IControllerMcpCaller, type IControllerMcpWireCaller,
|
|
8
|
+
type IWorkerClaudeJobRequest, type TWorkerClaudeJobObservation,
|
|
8
9
|
} from '../ts_interfaces/index.js';
|
|
9
10
|
import { anonymousControllerMcpCaller, ControllerMcpCallerRegistry } from './classes.mcpcallerregistry.js';
|
|
10
11
|
import {
|
|
11
12
|
resolveWorkerBridgeCommand, resolveClaudeWorkerExecutable, verifyClaudeWorkerProcess,
|
|
12
|
-
verifyWorkerBridge, workerSnapshotsEqual, WorkerCallerProofError, isWorkerCallerProofRefusal,
|
|
13
|
+
readWorkerBridge, verifyWorkerBridge, workerSnapshotsEqual, WorkerCallerProofError, isWorkerCallerProofRefusal,
|
|
13
14
|
type IWorkerCallerConnection, type IWorkerProcessSnapshot, type IWorkerBridgeCommand,
|
|
14
15
|
} from './functions.workerprocessproof.js';
|
|
16
|
+
import {
|
|
17
|
+
workerCallerDigest, workerProcessSnapshotDigest, type IWorkerCallerBindingStore, type IWorkerCallerBindingProcess,
|
|
18
|
+
type TWorkerCallerBindingRecord,
|
|
19
|
+
} from './classes.workercallerbindings.js';
|
|
15
20
|
|
|
16
21
|
export interface IWorkerCodexCallerOrigin {
|
|
17
22
|
parent: IWorkerProcessSnapshot;
|
|
@@ -47,24 +52,59 @@ interface IWorkerLaunch {
|
|
|
47
52
|
leases: number;
|
|
48
53
|
drained?: () => void;
|
|
49
54
|
serial: Promise<unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Bridge process images a job re-proof refused, by the digest of their whole snapshot, until when: a refused bridge is
|
|
57
|
+
* not proved again at every call. A bridge re-executes itself in place onto an upgraded build (same PID and start,
|
|
58
|
+
* another image), and the image it became is proved afresh.
|
|
59
|
+
*/
|
|
60
|
+
refusals: Map<string, number>;
|
|
61
|
+
/** The pending reasons already logged for this launch. */
|
|
62
|
+
logged: Set<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Whether this controller proved the binding: false for a launch restored from its kept binding until a fresh
|
|
65
|
+
* inventory shows its job still belongs to the worker, so a launch of an archived or moved worker is never taken over.
|
|
66
|
+
*/
|
|
67
|
+
verified: boolean;
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The outcome of a job re-proof: the launch is bound to the bridge's process, the bridge is refused (or was refused
|
|
72
|
+
* before and is not proved again yet), or the job ended for the worker.
|
|
73
|
+
*/
|
|
74
|
+
type TReproof = { kind: 'rebound' } | { kind: 'refused'; reason: string } | { kind: 'remembered' } | { kind: 'ended'; reason: string };
|
|
75
|
+
|
|
52
76
|
export interface IControllerWorkerCallerOptions {
|
|
53
77
|
controllerGeneration: string;
|
|
54
78
|
registry: ControllerMcpCallerRegistry;
|
|
55
79
|
readMaster(): Promise<IConfigurationMaster>;
|
|
56
80
|
captureCodexOrigin(launch: Extract<TFleetWorkerCallerLaunch, { harnessId: 'codex' }>): Promise<IWorkerCodexCallerOrigin>;
|
|
57
81
|
processProof?: IWorkerCallerProcessProof;
|
|
82
|
+
/** Keeps the bindings of fleet Claude launches, so a restarted controller proves their bridges again; absent keeps them in memory. */
|
|
83
|
+
bindings?: IWorkerCallerBindingStore;
|
|
84
|
+
/**
|
|
85
|
+
* A fresh read of the Claude background job a launch is bound to, from the fleet's own inventory, one read at a time.
|
|
86
|
+
* Without it a bridge of a process that replaced the bound one stays pending.
|
|
87
|
+
*/
|
|
88
|
+
observeClaudeJob?(request: IWorkerClaudeJobRequest): Promise<TWorkerClaudeJobObservation>;
|
|
89
|
+
/** Where the reason a worker enrollment stays pending is written once; the controller log by default. */
|
|
90
|
+
log?(line: string): void;
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
export interface IWorkerCallerProcessProof {
|
|
61
94
|
resolveCommand: typeof resolveWorkerBridgeCommand;
|
|
62
95
|
resolveClaude: typeof resolveClaudeWorkerExecutable;
|
|
63
96
|
verifyClaude: typeof verifyClaudeWorkerProcess;
|
|
97
|
+
readBridge: typeof readWorkerBridge;
|
|
64
98
|
verifyBridge: typeof verifyWorkerBridge;
|
|
65
99
|
}
|
|
66
100
|
|
|
67
101
|
const pending = (): IWorkerCallerEnrollmentResponse => ({ callerPending: true, bindingRevision: 0 });
|
|
102
|
+
/** How long a bridge refused for a reason that may pass (an unread inventory, a job not yet in its process) waits before its next proof. */
|
|
103
|
+
const transientRefusalMs = 5_000;
|
|
104
|
+
const maximumRefusals = 64;
|
|
105
|
+
const maximumLoggedReasons = 4096;
|
|
106
|
+
const proofReason = (errorArg: unknown): string => errorArg instanceof WorkerCallerProofError
|
|
107
|
+
? errorArg.message : `a process could not be read (${(errorArg as NodeJS.ErrnoException).code ?? 'unknown'})`;
|
|
68
108
|
const assertIdentifier = (valueArg: string): void => {
|
|
69
109
|
if (typeof valueArg !== 'string' || !/^[A-Za-z0-9_.:-]{1,256}$/u.test(valueArg)) throw new Error('Invalid worker launch identifier.');
|
|
70
110
|
};
|
|
@@ -73,41 +113,51 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
73
113
|
private readonly launches = new Map<string, IWorkerLaunch>();
|
|
74
114
|
private readonly sessions = new Map<string, IWorkerLaunch>();
|
|
75
115
|
private readonly challenges = new Map<string, IWorkerLaunch>();
|
|
116
|
+
/** Each worker's current launch, prepared by this controller or kept from before its restart. */
|
|
117
|
+
private readonly workers = new Map<string, IWorkerLaunch>();
|
|
118
|
+
/** Per worker, the chain its kept binding's writes, deletion and restoration run on, one at a time. */
|
|
119
|
+
private readonly workerTails = new Map<string, Promise<unknown>>();
|
|
120
|
+
/** Pending reasons logged for launches this controller does not hold, by launch digest and reason. */
|
|
121
|
+
private readonly loggedUnknown = new Set<string>();
|
|
76
122
|
private closed = false;
|
|
77
123
|
private readonly proof: IWorkerCallerProcessProof;
|
|
124
|
+
private readonly log: (lineArg: string) => void;
|
|
78
125
|
|
|
79
126
|
constructor(private readonly options: IControllerWorkerCallerOptions) {
|
|
80
|
-
this.proof = options.processProof ?? { resolveCommand: resolveWorkerBridgeCommand,
|
|
81
|
-
|
|
127
|
+
this.proof = options.processProof ?? { resolveCommand: resolveWorkerBridgeCommand, resolveClaude: resolveClaudeWorkerExecutable,
|
|
128
|
+
verifyClaude: verifyClaudeWorkerProcess, readBridge: readWorkerBridge, verifyBridge: verifyWorkerBridge };
|
|
129
|
+
this.log = options.log ?? ((lineArg) => console.warn(lineArg));
|
|
82
130
|
}
|
|
83
131
|
|
|
84
132
|
public async prepareWorkerCaller(launchArg: TFleetWorkerCallerLaunch): ReturnType<IFleetWorkerCallerHooks['prepareWorkerCaller']> {
|
|
85
133
|
assertIdentifier(launchArg.launchId);
|
|
86
134
|
assertIdentifier(launchArg.projectId);
|
|
135
|
+
if (typeof launchArg.workerId !== 'string' || launchArg.workerId.length > 1024 || !launchArg.workerId.startsWith(`${launchArg.projectId}:`)) {
|
|
136
|
+
throw new Error('Invalid worker launch identifier.');
|
|
137
|
+
}
|
|
138
|
+
const refusable = () => this.closed || this.launches.has(launchArg.launchId) || !['claude', 'codex'].includes(launchArg.harnessId);
|
|
139
|
+
if (refusable()) throw new Error('Worker launch cannot be prepared.');
|
|
140
|
+
// A worker holds one current launch; one kept from before a restart gives way like any other.
|
|
141
|
+
const previous = this.workers.get(launchArg.workerId);
|
|
142
|
+
if (previous && previous.state !== 'revoked') await this.revokeWorkerCaller({ launchId: previous.launch.launchId, reason: 'replacement' });
|
|
87
143
|
// A revoked launch stays as a tombstone so its id is never prepared again; the oldest give way to new launches.
|
|
88
144
|
for (const [launchId, record] of this.launches) {
|
|
89
145
|
if (this.launches.size < 65536) break;
|
|
90
146
|
if (record.state === 'revoked' && launchId !== launchArg.launchId) this.launches.delete(launchId);
|
|
91
147
|
}
|
|
92
|
-
if (
|
|
93
|
-
|| !['claude', 'codex'].includes(launchArg.harnessId)) throw new Error('Worker launch cannot be prepared.');
|
|
148
|
+
if (refusable() || this.launches.size >= 65536) throw new Error('Worker launch cannot be prepared.');
|
|
94
149
|
const record: IWorkerLaunch = { launch: structuredClone(launchArg), generation: plugins.crypto.randomBytes(24).toString('base64url'),
|
|
95
|
-
state: 'prepared', revision: 0, credentials: new Map(), leases: 0, serial: Promise.resolve() };
|
|
150
|
+
state: 'prepared', revision: 0, credentials: new Map(), leases: 0, serial: Promise.resolve(), refusals: new Map(), logged: new Set(), verified: true };
|
|
96
151
|
this.launches.set(launchArg.launchId, record);
|
|
152
|
+
this.workers.set(launchArg.workerId, record);
|
|
97
153
|
try {
|
|
98
|
-
const
|
|
99
|
-
const servers = master.mcpServers.filter(server => server.name === 'agl'
|
|
100
|
-
&& (!server.targets || server.targets.includes(launchArg.harnessId)));
|
|
101
|
-
if (servers.length !== 1 || servers[0].transport !== 'stdio'
|
|
102
|
-
|| Object.keys(servers[0].envFrom).length > 0
|
|
103
|
-
|| Object.hasOwn(servers[0].env, controllerMcpCallerCredentialEnvironmentVariable)) {
|
|
104
|
-
throw new Error('The Configuration master must define one non-secret stdio agl server for this harness.');
|
|
105
|
-
}
|
|
106
|
-
const server = servers[0];
|
|
154
|
+
const server = await this.approvedServer(launchArg.harnessId);
|
|
107
155
|
record.command = await this.proof.resolveCommand(server);
|
|
108
156
|
if (launchArg.harnessId === 'claude') record.claudeExecutable = await this.proof.resolveClaude();
|
|
109
157
|
else record.codex = await this.options.captureCodexOrigin(launchArg);
|
|
110
158
|
this.requireState(record, 'prepared');
|
|
159
|
+
await this.keep(record, 0);
|
|
160
|
+
this.requireState(record, 'prepared');
|
|
111
161
|
return { launchGeneration: record.generation, bindingRevision: 0, callerPending: true,
|
|
112
162
|
...(launchArg.harnessId === 'claude' ? { claudeMcpConfig: { mcpServers: { agl: {
|
|
113
163
|
...structuredClone(server.native?.claude ?? {}), type: 'stdio', command: server.command, args: [...server.args],
|
|
@@ -126,42 +176,7 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
126
176
|
public async bindWorkerCaller(bindingArg: TFleetWorkerCallerBinding): ReturnType<IFleetWorkerCallerHooks['bindWorkerCaller']> {
|
|
127
177
|
const record = this.launches.get(bindingArg.launchId);
|
|
128
178
|
if (!record) throw new Error('The worker launch is unavailable.');
|
|
129
|
-
await this.serialize(record,
|
|
130
|
-
if (this.closed || !['prepared', 'bound'].includes(record.state)
|
|
131
|
-
|| bindingArg.projectId !== record.launch.projectId || bindingArg.harnessId !== record.launch.harnessId
|
|
132
|
-
|| bindingArg.expectedBindingRevision !== record.revision || !record.command
|
|
133
|
-
|| (record.binding && bindingArg.nativeSessionId !== record.binding.nativeSessionId)
|
|
134
|
-
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(bindingArg.nativeSessionId)) {
|
|
135
|
-
throw new Error('The worker binding is stale or inconsistent.');
|
|
136
|
-
}
|
|
137
|
-
const ticketSessionId = `${bindingArg.harnessId}:${bindingArg.nativeSessionId}`;
|
|
138
|
-
const existing = this.sessions.get(ticketSessionId);
|
|
139
|
-
if (existing && existing !== record) throw new Error('The native worker session already belongs to another launch.');
|
|
140
|
-
let parent: IWorkerProcessSnapshot;
|
|
141
|
-
if (bindingArg.harnessId === 'claude') {
|
|
142
|
-
parent = await this.proof.verifyClaude(bindingArg.claudeProcess, record.claudeExecutable!);
|
|
143
|
-
if (record.binding?.harnessId === 'claude' && record.binding.claudeProcess.jobId !== bindingArg.claudeProcess.jobId) {
|
|
144
|
-
throw new Error('A worker binding cannot change its native job.');
|
|
145
|
-
}
|
|
146
|
-
} else {
|
|
147
|
-
if (!record.codex || !await record.codex.isCurrent()) throw new Error('The Codex launch origin is no longer current.');
|
|
148
|
-
parent = record.codex.parent;
|
|
149
|
-
}
|
|
150
|
-
if (this.closed || record.state === 'revoked' || record.state === 'quiescing') throw new Error('The worker launch was revoked.');
|
|
151
|
-
const other = this.sessions.get(ticketSessionId);
|
|
152
|
-
if (other && other !== record) throw new Error('The native worker session already belongs to another launch.');
|
|
153
|
-
this.sessions.set(ticketSessionId, record);
|
|
154
|
-
record.state = 'quiescing';
|
|
155
|
-
this.clearCredentials(record);
|
|
156
|
-
this.clearChallenge(record);
|
|
157
|
-
await this.drain(record);
|
|
158
|
-
this.requireState(record, 'quiescing');
|
|
159
|
-
record.binding = structuredClone(bindingArg);
|
|
160
|
-
record.parent = parent;
|
|
161
|
-
record.ticketSessionId = ticketSessionId;
|
|
162
|
-
record.revision += 1;
|
|
163
|
-
record.state = 'bound';
|
|
164
|
-
});
|
|
179
|
+
await this.serialize(record, () => this.bind(record, bindingArg));
|
|
165
180
|
if (bindingArg.harnessId === 'codex') {
|
|
166
181
|
try { await this.challenge(record); } catch (errorArg) {
|
|
167
182
|
if (record.revision === bindingArg.expectedBindingRevision + 1) {
|
|
@@ -183,21 +198,35 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
183
198
|
}
|
|
184
199
|
|
|
185
200
|
public async revokeWorkerCaller(requestArg: Parameters<IFleetWorkerCallerHooks['revokeWorkerCaller']>[0]): Promise<void> {
|
|
201
|
+
const forGood = requestArg.reason !== 'generation_change';
|
|
202
|
+
if ('workerId' in requestArg) {
|
|
203
|
+
const workerId = requestArg.workerId;
|
|
204
|
+
// Marked revoked and deleted in one step of the worker's chain, so no restoration or bind of it can follow.
|
|
205
|
+
const current = await this.withWorker(workerId, async () => {
|
|
206
|
+
const record = this.workers.get(workerId);
|
|
207
|
+
if (record) this.markRevoked(record);
|
|
208
|
+
if (forGood) await this.options.bindings?.delete(workerId);
|
|
209
|
+
return record;
|
|
210
|
+
});
|
|
211
|
+
if (current) await this.settleRevocation(current);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
186
214
|
const record = this.launches.get(requestArg.launchId);
|
|
187
215
|
if (!record) return;
|
|
188
|
-
record
|
|
189
|
-
this.
|
|
190
|
-
this.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
this.markRevoked(record);
|
|
217
|
+
await this.settleRevocation(record);
|
|
218
|
+
const bindings = this.options.bindings;
|
|
219
|
+
if (forGood && bindings && record.launch.harnessId === 'claude') {
|
|
220
|
+
await this.withWorker(record.launch.workerId, () => bindings.delete(record.launch.workerId, workerCallerDigest(record.launch.launchId)));
|
|
221
|
+
}
|
|
194
222
|
}
|
|
195
223
|
|
|
196
224
|
public async enroll(requestArg: TWorkerCallerEnrollment, connectionArg?: IWorkerCallerConnection): Promise<IWorkerCallerEnrollmentResponse> {
|
|
197
225
|
let record: IWorkerLaunch | undefined;
|
|
198
226
|
if ('launchId' in requestArg) {
|
|
199
|
-
record = this.launches.get(requestArg.launchId);
|
|
227
|
+
record = this.launches.get(requestArg.launchId) ?? await this.restore(requestArg);
|
|
200
228
|
if (!record || record.launch.harnessId !== 'claude' || record.generation !== requestArg.launchGeneration) return pending();
|
|
229
|
+
return this.enrollClaude(record, requestArg.bridgePid, connectionArg);
|
|
201
230
|
} else if ('challenge' in requestArg) {
|
|
202
231
|
record = this.challenges.get(requestArg.challenge);
|
|
203
232
|
if (!record || record.binding?.nativeSessionId !== requestArg.nativeThreadId || record.launch.harnessId !== 'codex') return pending();
|
|
@@ -252,6 +281,328 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
|
|
|
252
281
|
await Promise.all([...this.launches.keys()].map(launchId => this.revokeWorkerCaller({ launchId, reason: 'generation_change' })));
|
|
253
282
|
}
|
|
254
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Binds `recordArg` to `bindingArg`, inside its serial chain: a Claude launch to its background job and, when it has
|
|
286
|
+
* one, the job's process; a Codex launch to its thread on the proved origin. The kept binding is written first.
|
|
287
|
+
*/
|
|
288
|
+
private async bind(record: IWorkerLaunch, bindingArg: TFleetWorkerCallerBinding, commandArg?: IWorkerBridgeCommand): Promise<void> {
|
|
289
|
+
if (this.closed || !['prepared', 'bound'].includes(record.state)
|
|
290
|
+
|| bindingArg.projectId !== record.launch.projectId || bindingArg.harnessId !== record.launch.harnessId
|
|
291
|
+
|| bindingArg.expectedBindingRevision !== record.revision || !record.command
|
|
292
|
+
|| (record.binding && bindingArg.nativeSessionId !== record.binding.nativeSessionId)
|
|
293
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(bindingArg.nativeSessionId)
|
|
294
|
+
|| (bindingArg.harnessId === 'claude' && (typeof bindingArg.claudeJobId !== 'string' || !bindingArg.claudeJobId
|
|
295
|
+
|| (bindingArg.claudeProcess && bindingArg.claudeProcess.jobId !== bindingArg.claudeJobId)))) {
|
|
296
|
+
throw new Error('The worker binding is stale or inconsistent.');
|
|
297
|
+
}
|
|
298
|
+
const ticketSessionId = `${bindingArg.harnessId}:${bindingArg.nativeSessionId}`;
|
|
299
|
+
const existing = this.sessions.get(ticketSessionId);
|
|
300
|
+
if (existing && existing !== record) throw new Error('The native worker session already belongs to another launch.');
|
|
301
|
+
let parent: IWorkerProcessSnapshot | undefined;
|
|
302
|
+
if (bindingArg.harnessId === 'claude') {
|
|
303
|
+
if (record.binding?.harnessId === 'claude' && record.binding.claudeJobId !== bindingArg.claudeJobId) {
|
|
304
|
+
throw new Error('A worker binding cannot change its native job.');
|
|
305
|
+
}
|
|
306
|
+
// A job whose process already exited binds without one; the job's next process proves itself as its bridge enrolls.
|
|
307
|
+
parent = bindingArg.claudeProcess ? await this.proof.verifyClaude(bindingArg.claudeProcess, record.claudeExecutable!) : undefined;
|
|
308
|
+
} else {
|
|
309
|
+
if (!record.codex || !await record.codex.isCurrent()) throw new Error('The Codex launch origin is no longer current.');
|
|
310
|
+
parent = record.codex.parent;
|
|
311
|
+
}
|
|
312
|
+
if (this.closed || record.state === 'revoked' || record.state === 'quiescing') throw new Error('The worker launch was revoked.');
|
|
313
|
+
const other = this.sessions.get(ticketSessionId);
|
|
314
|
+
if (other && other !== record) throw new Error('The native worker session already belongs to another launch.');
|
|
315
|
+
if (bindingArg.harnessId === 'claude') {
|
|
316
|
+
await this.keep(record, record.revision + 1, { nativeSessionId: bindingArg.nativeSessionId, jobId: bindingArg.claudeJobId,
|
|
317
|
+
...(parent ? { process: { pid: parent.pid, startTicks: parent.startTicks, snapshotDigest: workerProcessSnapshotDigest(parent) } } : {}) },
|
|
318
|
+
commandArg);
|
|
319
|
+
if (this.closed || record.state !== 'prepared' && record.state !== 'bound') throw new Error('The worker launch was revoked.');
|
|
320
|
+
}
|
|
321
|
+
this.sessions.set(ticketSessionId, record);
|
|
322
|
+
record.state = 'quiescing';
|
|
323
|
+
this.clearCredentials(record);
|
|
324
|
+
this.clearChallenge(record);
|
|
325
|
+
await this.drain(record);
|
|
326
|
+
this.requireState(record, 'quiescing');
|
|
327
|
+
record.binding = structuredClone(bindingArg);
|
|
328
|
+
record.parent = parent;
|
|
329
|
+
if (commandArg) record.command = structuredClone(commandArg);
|
|
330
|
+
record.ticketSessionId = ticketSessionId;
|
|
331
|
+
record.revision += 1;
|
|
332
|
+
record.state = 'bound';
|
|
333
|
+
record.verified = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Enrolls the bridge of a Claude launch: against the process the launch is bound to, or, when that process is gone or
|
|
338
|
+
* replaced, after the fleet proved the bridge's process to be the next process of the same job.
|
|
339
|
+
*/
|
|
340
|
+
private async enrollClaude(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection): Promise<IWorkerCallerEnrollmentResponse> {
|
|
341
|
+
if (recordArg.state === 'bound' && recordArg.parent && recordArg.verified) {
|
|
342
|
+
try {
|
|
343
|
+
const response = await this.enrollCredential(recordArg, bridgePidArg, connectionArg);
|
|
344
|
+
if (!response.callerPending) return response;
|
|
345
|
+
} catch (errorArg) {
|
|
346
|
+
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (recordArg.state !== 'bound') {
|
|
350
|
+
this.logPending(recordArg, recordArg.state === 'revoked' ? 'its launch was revoked' : 'its launch is not bound to a Claude job yet');
|
|
351
|
+
return pending();
|
|
352
|
+
}
|
|
353
|
+
const outcome = await this.reprove(recordArg, bridgePidArg, connectionArg);
|
|
354
|
+
if (outcome.kind === 'ended') {
|
|
355
|
+
this.logPending(recordArg, `${outcome.reason}; its launch is revoked`);
|
|
356
|
+
await this.revokeWorkerCaller({ launchId: recordArg.launch.launchId, reason: 'failure' });
|
|
357
|
+
return pending();
|
|
358
|
+
}
|
|
359
|
+
if (outcome.kind === 'refused' || outcome.kind === 'remembered') {
|
|
360
|
+
if (outcome.kind === 'refused') this.logPending(recordArg, outcome.reason);
|
|
361
|
+
return pending();
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
return await this.enrollCredential(recordArg, bridgePidArg, connectionArg);
|
|
365
|
+
} catch (errorArg) {
|
|
366
|
+
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
367
|
+
this.logPending(recordArg, proofReason(errorArg));
|
|
368
|
+
return pending();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Proves the bridge's process to be the current process of the job the launch is bound to, from a fresh inventory the
|
|
374
|
+
* fleet reads, and binds the launch to it at the current revision. A bridge refused for good is remembered for its
|
|
375
|
+
* process's life, one refused for a reason that may pass for a short while; neither is proved again at every call.
|
|
376
|
+
*/
|
|
377
|
+
private async reprove(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection): Promise<TReproof> {
|
|
378
|
+
const observe = this.options.observeClaudeJob;
|
|
379
|
+
if (!observe) return { kind: 'refused', reason: 'the process its launch is bound to is gone, and no fleet proves the job\'s next process' };
|
|
380
|
+
let bridge: IWorkerProcessSnapshot, command = recordArg.command!;
|
|
381
|
+
try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, command); } catch (errorArg) {
|
|
382
|
+
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
383
|
+
// An upgrade re-executes a bridge in place onto the build the controller runs now: it runs the command this
|
|
384
|
+
// controller resolves from its own configured `agl` entry, never one the bridge names.
|
|
385
|
+
let current: IWorkerBridgeCommand;
|
|
386
|
+
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'})` };
|
|
388
|
+
}
|
|
389
|
+
if (plugins.util.isDeepStrictEqual(current, command)) return { kind: 'refused', reason: proofReason(errorArg) };
|
|
390
|
+
try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, current); } catch (currentArg) {
|
|
391
|
+
if (!isWorkerCallerProofRefusal(currentArg)) throw currentArg;
|
|
392
|
+
return { kind: 'refused', reason: proofReason(errorArg) };
|
|
393
|
+
}
|
|
394
|
+
command = current;
|
|
395
|
+
}
|
|
396
|
+
const key = workerProcessSnapshotDigest(bridge);
|
|
397
|
+
if ((recordArg.refusals.get(key) ?? 0) > Date.now()) return { kind: 'remembered' };
|
|
398
|
+
return this.serialize(recordArg, async (): Promise<TReproof> => {
|
|
399
|
+
const record = recordArg, binding = record.binding;
|
|
400
|
+
if (this.closed || record.state !== 'bound' || binding?.harnessId !== 'claude' || !record.command) {
|
|
401
|
+
return { kind: 'refused', reason: 'its launch is no longer bound' };
|
|
402
|
+
}
|
|
403
|
+
// Read in the serial chain: an enrollment that ran before may have kept the bridge's new command already.
|
|
404
|
+
const sameCommand = plugins.util.isDeepStrictEqual(command, record.command);
|
|
405
|
+
// An enrollment that waited here finds the launch bound to this bridge's process already.
|
|
406
|
+
if (sameCommand && record.verified && record.parent && record.parent.pid === bridge.parentPid
|
|
407
|
+
&& await this.processStands(record, record.parent)) return { kind: 'rebound' };
|
|
408
|
+
const refuse = (reasonArg: string, forGoodArg: boolean): TReproof => {
|
|
409
|
+
if (record.refusals.size >= maximumRefusals) record.refusals.delete(record.refusals.keys().next().value!);
|
|
410
|
+
record.refusals.set(key, forGoodArg ? Number.POSITIVE_INFINITY : Date.now() + transientRefusalMs);
|
|
411
|
+
return { kind: 'refused', reason: reasonArg };
|
|
412
|
+
};
|
|
413
|
+
const observed = await observe({ workerId: record.launch.workerId, projectId: record.launch.projectId,
|
|
414
|
+
nativeSessionId: binding.nativeSessionId, jobId: binding.claudeJobId });
|
|
415
|
+
if (observed.state === 'ended') return { kind: 'ended', reason: observed.reason };
|
|
416
|
+
if (observed.state === 'unproven') return refuse(observed.reason, false);
|
|
417
|
+
if (observed.state === 'settled') return refuse('its Claude job lists no running process', false);
|
|
418
|
+
if (this.closed || record.state !== 'bound') return { kind: 'refused', reason: 'its launch is no longer bound' };
|
|
419
|
+
if (observed.process.jobId !== binding.claudeJobId) return { kind: 'ended', reason: 'its session continues as another Claude job' };
|
|
420
|
+
// The inventory can still list the process Claude Code just replaced, so this refusal may pass.
|
|
421
|
+
if (observed.process.pid !== bridge.parentPid) return refuse('its bridge does not run under the process of its Claude job', false);
|
|
422
|
+
// A restored launch whose kept process is still the job's: the inventory confirmed it, and nothing is bound again.
|
|
423
|
+
if (sameCommand && record.parent && record.parent.pid === observed.process.pid && record.parent.startTicks === observed.process.startTicks
|
|
424
|
+
&& await this.processStands(record, record.parent)) {
|
|
425
|
+
record.verified = true;
|
|
426
|
+
return { kind: 'rebound' };
|
|
427
|
+
}
|
|
428
|
+
let executable: string, parent: IWorkerProcessSnapshot;
|
|
429
|
+
try { executable = await this.proof.resolveClaude(); } catch {
|
|
430
|
+
return refuse('the approved Claude Code executable cannot be resolved', false);
|
|
431
|
+
}
|
|
432
|
+
try { parent = await this.proof.verifyClaude(observed.process, executable); } catch (errorArg) {
|
|
433
|
+
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
434
|
+
return refuse('the process of its Claude job is not the approved Claude Code executable', false);
|
|
435
|
+
}
|
|
436
|
+
try { await this.proof.verifyBridge(connectionArg, bridge.pid, command, parent); } catch (errorArg) {
|
|
437
|
+
if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
|
|
438
|
+
return refuse('its bridge is not proved to be a direct child of the process of its Claude job', true);
|
|
439
|
+
}
|
|
440
|
+
record.claudeExecutable = executable;
|
|
441
|
+
try {
|
|
442
|
+
await this.bind(record, { launchId: record.launch.launchId, projectId: record.launch.projectId, harnessId: 'claude',
|
|
443
|
+
nativeSessionId: binding.nativeSessionId, expectedBindingRevision: record.revision, claudeJobId: binding.claudeJobId,
|
|
444
|
+
claudeProcess: observed.process }, sameCommand ? undefined : command);
|
|
445
|
+
} catch (errorArg) {
|
|
446
|
+
// Revoked while it was proved: the revocation wins.
|
|
447
|
+
if (this.revoked(record)) return { kind: 'refused', reason: 'its launch was revoked' };
|
|
448
|
+
throw errorArg;
|
|
449
|
+
}
|
|
450
|
+
return { kind: 'rebound' };
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Whether the Claude process `processArg` the launch was proved against still runs, unchanged. */
|
|
455
|
+
private async processStands(recordArg: IWorkerLaunch, processArg: IWorkerProcessSnapshot): Promise<boolean> {
|
|
456
|
+
if (recordArg.binding?.harnessId !== 'claude' || !recordArg.claudeExecutable) return false;
|
|
457
|
+
try {
|
|
458
|
+
return workerSnapshotsEqual(processArg, await this.proof.verifyClaude({ jobId: recordArg.binding.claudeJobId,
|
|
459
|
+
pid: processArg.pid, startTicks: processArg.startTicks }, recordArg.claudeExecutable));
|
|
460
|
+
} catch (errorArg) {
|
|
461
|
+
if (isWorkerCallerProofRefusal(errorArg)) return false;
|
|
462
|
+
throw errorArg;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The launch a restarted controller kept for a bridge that presents it, as it was bound: found by its id's digest,
|
|
468
|
+
* checked against its generation's, and only while it is still its worker's current launch. It is restored
|
|
469
|
+
* unverified: its bridge enrolls only after a fresh inventory shows the job still the worker's, in the kept process
|
|
470
|
+
* when that still runs unchanged, or in the bridge's process, which then proves itself as the job's next process.
|
|
471
|
+
*/
|
|
472
|
+
private async restore(requestArg: { launchId: string; launchGeneration: string }): Promise<IWorkerLaunch | undefined> {
|
|
473
|
+
const store = this.options.bindings;
|
|
474
|
+
if (!store || this.closed) return undefined;
|
|
475
|
+
const launchDigest = workerCallerDigest(requestArg.launchId);
|
|
476
|
+
const found = await store.find(launchDigest);
|
|
477
|
+
const refusal = !found ? 'its launch is not the current launch of a fleet worker'
|
|
478
|
+
: found.generationDigest !== workerCallerDigest(requestArg.launchGeneration) ? 'its launch generation does not match'
|
|
479
|
+
: !found.binding ? 'its launch was never bound to a Claude job' : undefined;
|
|
480
|
+
if (!found || refusal) {
|
|
481
|
+
this.logUnknown(launchDigest, refusal!);
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
return this.withWorker(found.id, async () => {
|
|
485
|
+
const kept = await store.find(launchDigest);
|
|
486
|
+
if (!kept?.binding || kept.id !== found.id || kept.generationDigest !== found.generationDigest) {
|
|
487
|
+
this.logUnknown(launchDigest, 'its worker holds another launch now');
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
const ticketSessionId = `claude:${kept.binding.nativeSessionId}`;
|
|
491
|
+
const record: IWorkerLaunch = { launch: { launchId: requestArg.launchId, projectId: kept.projectId, workerId: kept.id, harnessId: 'claude' },
|
|
492
|
+
generation: requestArg.launchGeneration, state: 'bound', revision: kept.revision, command: structuredClone(kept.command),
|
|
493
|
+
claudeExecutable: kept.claudeExecutable, binding: { launchId: requestArg.launchId, projectId: kept.projectId, harnessId: 'claude',
|
|
494
|
+
nativeSessionId: kept.binding.nativeSessionId, expectedBindingRevision: kept.revision - 1, claudeJobId: kept.binding.jobId },
|
|
495
|
+
ticketSessionId, credentials: new Map(), leases: 0, serial: Promise.resolve(), refusals: new Map(), logged: new Set(), verified: false };
|
|
496
|
+
const parent = kept.binding.process ? await this.keptProcess(record, kept.binding.process) : undefined;
|
|
497
|
+
if (parent) record.parent = parent;
|
|
498
|
+
// Decided in one step after the last await: a launch prepared or restored meanwhile wins.
|
|
499
|
+
const held = this.launches.get(requestArg.launchId);
|
|
500
|
+
if (held) return held;
|
|
501
|
+
const current = this.workers.get(kept.id), other = this.sessions.get(ticketSessionId);
|
|
502
|
+
if (this.closed || current && current.state !== 'revoked' || this.launches.size >= 65536) {
|
|
503
|
+
this.logUnknown(launchDigest, 'its worker holds another launch now');
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
if (other && other.state !== 'revoked') {
|
|
507
|
+
this.logUnknown(launchDigest, 'its session belongs to another launch');
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
this.launches.set(requestArg.launchId, record);
|
|
511
|
+
this.workers.set(kept.id, record);
|
|
512
|
+
this.sessions.set(ticketSessionId, record);
|
|
513
|
+
return record;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The kept process of a restored launch while it still runs as it was proved: the same PID, start and snapshot. */
|
|
518
|
+
private async keptProcess(recordArg: IWorkerLaunch, processArg: IWorkerCallerBindingProcess): Promise<IWorkerProcessSnapshot | undefined> {
|
|
519
|
+
if (recordArg.binding?.harnessId !== 'claude') return undefined;
|
|
520
|
+
try {
|
|
521
|
+
const snapshot = await this.proof.verifyClaude({ jobId: recordArg.binding.claudeJobId, pid: processArg.pid, startTicks: processArg.startTicks },
|
|
522
|
+
recordArg.claudeExecutable!);
|
|
523
|
+
return workerProcessSnapshotDigest(snapshot) === processArg.snapshotDigest ? snapshot : undefined;
|
|
524
|
+
} catch (errorArg) {
|
|
525
|
+
if (isWorkerCallerProofRefusal(errorArg)) return undefined;
|
|
526
|
+
throw errorArg;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Keeps the binding of a Claude launch at `revisionArg`: prepared, or bound to `bindingArg`. Refused once the launch is revoked. */
|
|
531
|
+
private async keep(recordArg: IWorkerLaunch, revisionArg: number, bindingArg?: TWorkerCallerBindingRecord['binding'],
|
|
532
|
+
commandArg: IWorkerBridgeCommand = recordArg.command!): Promise<void> {
|
|
533
|
+
const store = this.options.bindings;
|
|
534
|
+
if (!store || recordArg.launch.harnessId !== 'claude') return;
|
|
535
|
+
await this.withWorker(recordArg.launch.workerId, async () => {
|
|
536
|
+
if (this.closed || recordArg.state === 'revoked') throw new Error('The worker launch was revoked.');
|
|
537
|
+
await store.save({ id: recordArg.launch.workerId, projectId: recordArg.launch.projectId, harnessId: 'claude',
|
|
538
|
+
launchDigest: workerCallerDigest(recordArg.launch.launchId), generationDigest: workerCallerDigest(recordArg.generation),
|
|
539
|
+
command: structuredClone(commandArg), claudeExecutable: recordArg.claudeExecutable!, revision: revisionArg,
|
|
540
|
+
...(bindingArg ? { binding: structuredClone(bindingArg) } : {}) });
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** The one non-secret stdio `agl` MCP server the Configuration master defines for `harnessArg`. */
|
|
545
|
+
private async approvedServer(harnessArg: 'claude' | 'codex'): Promise<IConfigurationMcpStdioServer> {
|
|
546
|
+
const master = await this.options.readMaster();
|
|
547
|
+
const servers = master.mcpServers.filter(server => server.name === 'agl' && (!server.targets || server.targets.includes(harnessArg)));
|
|
548
|
+
const server = servers[0];
|
|
549
|
+
if (servers.length !== 1 || server?.transport !== 'stdio' || Object.keys(server.envFrom).length > 0
|
|
550
|
+
|| Object.hasOwn(server.env, controllerMcpCallerCredentialEnvironmentVariable)) {
|
|
551
|
+
throw new Error('The Configuration master must define one non-secret stdio agl server for this harness.');
|
|
552
|
+
}
|
|
553
|
+
return server;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The bridge command this controller approves now for Claude launches, resolved from its own configured `agl` entry. */
|
|
557
|
+
private async currentCommand(): Promise<IWorkerBridgeCommand> {
|
|
558
|
+
return this.proof.resolveCommand(await this.approvedServer('claude'));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Runs `operationArg` on the worker's chain: kept-binding writes, deletion and restoration of one worker never interleave. */
|
|
562
|
+
private withWorker<T>(workerIdArg: string, operationArg: () => Promise<T>): Promise<T> {
|
|
563
|
+
const task = (this.workerTails.get(workerIdArg) ?? Promise.resolve()).then(operationArg);
|
|
564
|
+
const tail = task.then(() => undefined, () => undefined);
|
|
565
|
+
this.workerTails.set(workerIdArg, tail);
|
|
566
|
+
void tail.then(() => { if (this.workerTails.get(workerIdArg) === tail) this.workerTails.delete(workerIdArg); });
|
|
567
|
+
return task;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Whether the launch was revoked, also by this controller closing; a method, so a check after an await reads the state again. */
|
|
571
|
+
private revoked(recordArg: IWorkerLaunch): boolean {
|
|
572
|
+
return this.closed || recordArg.state === 'revoked';
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private markRevoked(recordArg: IWorkerLaunch): void {
|
|
576
|
+
recordArg.state = 'revoked';
|
|
577
|
+
this.clearChallenge(recordArg);
|
|
578
|
+
this.clearCredentials(recordArg);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private async settleRevocation(recordArg: IWorkerLaunch): Promise<void> {
|
|
582
|
+
await this.drain(recordArg);
|
|
583
|
+
await recordArg.serial.catch(() => undefined);
|
|
584
|
+
if (recordArg.ticketSessionId && this.sessions.get(recordArg.ticketSessionId) === recordArg) this.sessions.delete(recordArg.ticketSessionId);
|
|
585
|
+
if (this.workers.get(recordArg.launch.workerId) === recordArg) this.workers.delete(recordArg.launch.workerId);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Writes once per launch and reason why its bridge's enrollment stays pending; never a credential or the launch id. */
|
|
589
|
+
private logPending(recordArg: IWorkerLaunch, reasonArg: string): void {
|
|
590
|
+
if (recordArg.logged.has(reasonArg)) return;
|
|
591
|
+
if (recordArg.logged.size >= 32) recordArg.logged.clear();
|
|
592
|
+
recordArg.logged.add(reasonArg);
|
|
593
|
+
this.log(`The MCP caller of fleet worker ${recordArg.launch.workerId}${recordArg.ticketSessionId ? ` (${recordArg.ticketSessionId})` : ''} `
|
|
594
|
+
+ `stays unverified: ${reasonArg}.`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** As `logPending`, for a launch this controller does not hold. */
|
|
598
|
+
private logUnknown(launchDigestArg: string, reasonArg: string): void {
|
|
599
|
+
const key = `${launchDigestArg}:${reasonArg}`;
|
|
600
|
+
if (this.loggedUnknown.has(key)) return;
|
|
601
|
+
if (this.loggedUnknown.size >= maximumLoggedReasons) this.loggedUnknown.clear();
|
|
602
|
+
this.loggedUnknown.add(key);
|
|
603
|
+
this.log(`A fleet worker's MCP caller stays unverified: ${reasonArg}.`);
|
|
604
|
+
}
|
|
605
|
+
|
|
255
606
|
private async enrollCredential(recordArg: IWorkerLaunch, bridgePidArg: number,
|
|
256
607
|
connectionArg?: IWorkerCallerConnection, challengeArg?: string): Promise<IWorkerCallerEnrollmentResponse> {
|
|
257
608
|
return this.serialize(recordArg, async () => {
|