@zgeoff/atc 2.24.0 → 2.25.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/package.json +1 -1
- package/src/agents/agent-adapter.ts +27 -2
- package/src/agents/gateway-adapter.ts +21 -3
- package/src/client/collect-target-picks.ts +5 -0
- package/src/client/spawn-picker.ts +15 -1
- package/src/daemon/broker-auth-host.ts +25 -0
- package/src/daemon/broker-authority-error.ts +2 -1
- package/src/daemon/build-agent-list.ts +16 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/build-target-list.ts +5 -0
- package/src/daemon/daemon-connection.ts +52 -2
- package/src/daemon/daemon-context.ts +12 -0
- package/src/daemon/daemon.ts +58 -1
- package/src/daemon/execution-provider.ts +50 -2
- package/src/daemon/imp-client-port.ts +71 -23
- package/src/daemon/imp-harness.ts +299 -18
- package/src/daemon/imp-port.ts +23 -1
- package/src/daemon/imp-provider.ts +161 -40
- package/src/daemon/local-pty-provider.ts +11 -0
- package/src/daemon/restore-fleet.ts +15 -1
- package/src/daemon/runtime-auth-binder.ts +1086 -0
- package/src/daemon/sessions.ts +565 -25
- package/src/daemon/verify-broker-authority.ts +10 -5
- package/src/mcp/mcp-tools.ts +2 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/protocol/daemon-features.ts +4 -0
- package/src/protocol/protocol.ts +16 -0
- package/src/protocol/request-param-schemas.ts +5 -0
- package/src/shared/collect-gateways.ts +1 -22
- package/src/shared/is-broker-variable.ts +24 -0
- package/src/store/state-store.ts +11 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
|
|
2
|
-
import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
|
|
2
|
+
import type { ExecOutcome, ExecSessionOptions, ImpClient } from '@zgeoff/imp-client';
|
|
3
3
|
import { isRecord } from '../shared/report';
|
|
4
4
|
import type {
|
|
5
5
|
ImpCommand,
|
|
6
6
|
ImpCommandResult,
|
|
7
7
|
ImpCreateSpec,
|
|
8
|
+
ImpExecRequirement,
|
|
8
9
|
ImpFeatures,
|
|
9
10
|
ImpIdentity,
|
|
10
11
|
ImpLease,
|
|
@@ -63,6 +64,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
63
64
|
leases: features?.['leases'] === true,
|
|
64
65
|
grantableTokens: features?.['grantableTokens'] === true,
|
|
65
66
|
secretRebind: features?.['secretRebind'] === true,
|
|
67
|
+
execRequire: features?.['execRequire'] === true,
|
|
66
68
|
};
|
|
67
69
|
};
|
|
68
70
|
|
|
@@ -199,6 +201,7 @@ export class ImpClientPort implements ImpPort {
|
|
|
199
201
|
readonly openSession = (
|
|
200
202
|
request: ImpSessionRequest,
|
|
201
203
|
handlers: ImpSessionHandlers,
|
|
204
|
+
gate?: () => boolean,
|
|
202
205
|
): ImpSessionConnection => {
|
|
203
206
|
const token = this.tryReadToken();
|
|
204
207
|
|
|
@@ -217,34 +220,14 @@ export class ImpClientPort implements ImpPort {
|
|
|
217
220
|
const session = openExecSession({
|
|
218
221
|
baseUrl: this.url,
|
|
219
222
|
token,
|
|
220
|
-
start:
|
|
221
|
-
request.kind === 'start'
|
|
222
|
-
? {
|
|
223
|
-
name: request.name,
|
|
224
|
-
session: request.session,
|
|
225
|
-
argv: request.argv,
|
|
226
|
-
tty: true,
|
|
227
|
-
env: request.env,
|
|
228
|
-
cwd: request.cwd,
|
|
229
|
-
cols: request.cols,
|
|
230
|
-
rows: request.rows,
|
|
231
|
-
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
232
|
-
}
|
|
233
|
-
: {
|
|
234
|
-
name: request.name,
|
|
235
|
-
session: request.session,
|
|
236
|
-
cols: request.cols,
|
|
237
|
-
rows: request.rows,
|
|
238
|
-
wake: request.wake,
|
|
239
|
-
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
240
|
-
},
|
|
223
|
+
start: buildExecStart(request),
|
|
241
224
|
onStarted: (started) => {
|
|
242
225
|
handlers.onStarted({ created: started.created, output: started.output });
|
|
243
226
|
},
|
|
244
227
|
onOutput: (_channel, data) => {
|
|
245
228
|
handlers.onOutput(data);
|
|
246
229
|
},
|
|
247
|
-
connect: (url, headers) =>
|
|
230
|
+
connect: (url, headers) => openGatedSocket(url, headers, gate),
|
|
248
231
|
});
|
|
249
232
|
|
|
250
233
|
return {
|
|
@@ -389,6 +372,41 @@ function toPortError(error: unknown): ImpPortError {
|
|
|
389
372
|
return new ImpPortError('UNREACHABLE', message);
|
|
390
373
|
}
|
|
391
374
|
|
|
375
|
+
// A start or an attach as the client takes it, with the requirements impd
|
|
376
|
+
// checks before the command runs or the attach joins it.
|
|
377
|
+
type ExecStartWithRequire = ExecSessionOptions['start'] & {
|
|
378
|
+
readonly require?: readonly ImpExecRequirement[];
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// The client sends every field of a start or an attach as it is, so a list
|
|
382
|
+
// of requirements reaches impd though the client's types lack it.
|
|
383
|
+
function buildExecStart(request: ImpSessionRequest): ExecStartWithRequire {
|
|
384
|
+
if (request.kind === 'attach') {
|
|
385
|
+
return {
|
|
386
|
+
name: request.name,
|
|
387
|
+
session: request.session,
|
|
388
|
+
cols: request.cols,
|
|
389
|
+
rows: request.rows,
|
|
390
|
+
wake: request.wake,
|
|
391
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
392
|
+
...(request.require === undefined ? {} : { require: request.require }),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
name: request.name,
|
|
398
|
+
session: request.session,
|
|
399
|
+
argv: request.argv,
|
|
400
|
+
tty: true,
|
|
401
|
+
env: request.env,
|
|
402
|
+
cwd: request.cwd,
|
|
403
|
+
cols: request.cols,
|
|
404
|
+
rows: request.rows,
|
|
405
|
+
...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
|
|
406
|
+
...(request.require === undefined ? {} : { require: request.require }),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
392
410
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
|
|
393
411
|
async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<ImpSessionOutcome> {
|
|
394
412
|
const ended = await outcome;
|
|
@@ -469,3 +487,33 @@ function openRelay(accept: RelayAccept): ImpRelayConnection {
|
|
|
469
487
|
},
|
|
470
488
|
};
|
|
471
489
|
}
|
|
490
|
+
|
|
491
|
+
// The client sends its request from the socket's open listener. The gate
|
|
492
|
+
// listens first, so a closed gate, or one that throws, stops that
|
|
493
|
+
// listener and closes the socket before anything goes out.
|
|
494
|
+
function openGatedSocket(
|
|
495
|
+
url: string,
|
|
496
|
+
headers: Readonly<Record<string, string>>,
|
|
497
|
+
gate: (() => boolean) | undefined,
|
|
498
|
+
): WebSocket {
|
|
499
|
+
const socket = new WebSocket(url, { headers });
|
|
500
|
+
|
|
501
|
+
if (gate !== undefined) {
|
|
502
|
+
socket.addEventListener('open', (event) => {
|
|
503
|
+
if (!tryPassGate(gate)) {
|
|
504
|
+
event.stopImmediatePropagation();
|
|
505
|
+
socket.close(1000, 'closed before sending');
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return socket;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function tryPassGate(gate: () => boolean): boolean {
|
|
514
|
+
try {
|
|
515
|
+
return gate();
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
1
2
|
import { isRecord } from '../shared/report';
|
|
2
|
-
import type { HarnessAttachment, HarnessExit, HarnessHandle } from './execution-provider';
|
|
3
3
|
import type {
|
|
4
|
+
HarnessAttachment,
|
|
5
|
+
HarnessExit,
|
|
6
|
+
HarnessHandle,
|
|
7
|
+
LaunchTicket,
|
|
8
|
+
} from './execution-provider';
|
|
9
|
+
import type {
|
|
10
|
+
ImpExecRequirement,
|
|
4
11
|
ImpPort,
|
|
5
12
|
ImpSessionConnection,
|
|
6
13
|
ImpSessionOutcome,
|
|
@@ -28,6 +35,15 @@ interface ImpHarnessHost {
|
|
|
28
35
|
// Settles once the harness may start, such as when its report socket
|
|
29
36
|
// listens; a rejection ends the harness before it starts.
|
|
30
37
|
readonly ready?: Promise<void>;
|
|
38
|
+
|
|
39
|
+
// Admits each request that has requirements by calling send, or rejects
|
|
40
|
+
// with the refusal that ends the harness, sending nothing. The ticket
|
|
41
|
+
// send gets is checked just before the request goes out, and a refusal
|
|
42
|
+
// from it ends the harness with nothing sent.
|
|
43
|
+
readonly admit?: (
|
|
44
|
+
kind: 'start' | 'attach',
|
|
45
|
+
send: (ticket: LaunchTicket) => void,
|
|
46
|
+
) => Promise<void>;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
// The bytes a fresh attach sends listeners ahead of its replay: reset the
|
|
@@ -55,6 +71,20 @@ export class ImpHarness implements HarnessHandle {
|
|
|
55
71
|
|
|
56
72
|
private readonly session: string;
|
|
57
73
|
|
|
74
|
+
// What impd must have ready before the start and every reattach, so an
|
|
75
|
+
// attach never joins the process past a requirement the start had.
|
|
76
|
+
private readonly require: readonly ImpExecRequirement[] | undefined;
|
|
77
|
+
|
|
78
|
+
// The request that starts the process, and whether it went out to impd.
|
|
79
|
+
// Until it has, a retry sends it again, since there is no process to
|
|
80
|
+
// attach to.
|
|
81
|
+
private readonly start: ImpSessionRequest;
|
|
82
|
+
|
|
83
|
+
private startSent = false;
|
|
84
|
+
|
|
85
|
+
// The admission of the open connection's request, while it has one.
|
|
86
|
+
private connectionTicket: LaunchTicket | null = null;
|
|
87
|
+
|
|
58
88
|
private readonly dataListeners = new Set<(data: string) => void>();
|
|
59
89
|
|
|
60
90
|
private readonly exitListeners = new Set<(exit: HarnessExit) => void>();
|
|
@@ -110,6 +140,16 @@ export class ImpHarness implements HarnessHandle {
|
|
|
110
140
|
|
|
111
141
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
112
142
|
|
|
143
|
+
// How the harness's start settled: started once a connection first
|
|
144
|
+
// starts the process or attaches to it, or the refusal it ended with
|
|
145
|
+
// before that; null until then. The waits on that answer meanwhile.
|
|
146
|
+
private startOutcome: 'started' | Readonly<DaemonError> | null = null;
|
|
147
|
+
|
|
148
|
+
private readonly startWaiters = new Set<PromiseWithResolvers<void>>();
|
|
149
|
+
|
|
150
|
+
// Why impd refused to start the harness, once it has given a reason.
|
|
151
|
+
private startRefusal: DaemonError | null = null;
|
|
152
|
+
|
|
113
153
|
// The terminal size the open connection's request asked for.
|
|
114
154
|
private requestedSize: { cols: number; rows: number } = { cols: 0, rows: 0 };
|
|
115
155
|
|
|
@@ -119,11 +159,13 @@ export class ImpHarness implements HarnessHandle {
|
|
|
119
159
|
this.host = host;
|
|
120
160
|
this.name = start.name;
|
|
121
161
|
this.session = start.session;
|
|
162
|
+
this.require = start.require;
|
|
163
|
+
this.start = start;
|
|
122
164
|
this.cols = start.cols;
|
|
123
165
|
this.rows = start.rows;
|
|
124
166
|
|
|
125
167
|
if (host.ready === undefined) {
|
|
126
|
-
this.
|
|
168
|
+
this.openCheckedConnection(start);
|
|
127
169
|
} else {
|
|
128
170
|
void this.startWhenReady(host.ready, start);
|
|
129
171
|
}
|
|
@@ -206,6 +248,22 @@ export class ImpHarness implements HarnessHandle {
|
|
|
206
248
|
return waited.promise;
|
|
207
249
|
};
|
|
208
250
|
|
|
251
|
+
readonly waitForStart = (): Promise<void> => {
|
|
252
|
+
if (this.startOutcome === 'started') {
|
|
253
|
+
return Promise.resolve();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (this.startOutcome !== null) {
|
|
257
|
+
return Promise.reject(this.startOutcome);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const waited = Promise.withResolvers<void>();
|
|
261
|
+
|
|
262
|
+
this.startWaiters.add(waited);
|
|
263
|
+
|
|
264
|
+
return waited.promise;
|
|
265
|
+
};
|
|
266
|
+
|
|
209
267
|
readonly detach = (): void => {
|
|
210
268
|
if (this.done) {
|
|
211
269
|
return;
|
|
@@ -235,28 +293,159 @@ export class ImpHarness implements HarnessHandle {
|
|
|
235
293
|
|
|
236
294
|
// A resize while the host readied changed the size the start asks for.
|
|
237
295
|
if (!this.done) {
|
|
238
|
-
this.
|
|
296
|
+
this.openCheckedConnection({ ...start, cols: this.cols, rows: this.rows });
|
|
239
297
|
}
|
|
240
298
|
}
|
|
241
299
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
300
|
+
// An impd from before exec requirements ignores them and runs the
|
|
301
|
+
// command anyway, so a request that has any goes out only after impd
|
|
302
|
+
// shows it honours them; otherwise the harness ends as impd's own
|
|
303
|
+
// client refuses an outdated impd, and nothing runs.
|
|
304
|
+
private openCheckedConnection(request: ImpSessionRequest): void {
|
|
305
|
+
if (request.require === undefined || request.require.length === 0) {
|
|
306
|
+
this.openConnection(request);
|
|
307
|
+
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
void this.openRequiredConnection(request);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async openRequiredConnection(request: ImpSessionRequest): Promise<void> {
|
|
315
|
+
let execRequire: boolean;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const features = await this.port.readFeatures();
|
|
319
|
+
|
|
320
|
+
execRequire = features.execRequire;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (!this.done && !this.host.isSuspending()) {
|
|
323
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
324
|
+
|
|
325
|
+
this.applyOutcome({ kind: 'unreachable', detail }, false);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (this.done || this.host.isSuspending()) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!execRequire) {
|
|
336
|
+
this.applyOutcome(
|
|
337
|
+
{
|
|
338
|
+
kind: 'failed',
|
|
339
|
+
code: 'PRECONDITION_FAILED',
|
|
340
|
+
message: `impd for imp ${this.name} does not honour exec requirements`,
|
|
341
|
+
data: { reason: 'impd_outdated' },
|
|
342
|
+
},
|
|
343
|
+
false,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const admit = this.host.admit;
|
|
350
|
+
|
|
351
|
+
if (admit === undefined) {
|
|
352
|
+
this.openConnection(request);
|
|
353
|
+
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await admit(request.kind, (ticket) => {
|
|
359
|
+
if (this.done || this.host.isSuspending()) {
|
|
360
|
+
ticket.release();
|
|
361
|
+
} else {
|
|
362
|
+
this.openConnection(request, ticket);
|
|
252
363
|
}
|
|
253
|
-
}
|
|
364
|
+
});
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!this.done) {
|
|
367
|
+
this.applyAdmissionRefusal(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Whether an admitted request may still go out as its connection opens.
|
|
373
|
+
// A refusal there, or a check that throws, closes the connection unsent
|
|
374
|
+
// and ends the harness with it, once the port has returned to the
|
|
375
|
+
// caller. Only a start that passes counts as sent.
|
|
376
|
+
private checkTicket(ticket: LaunchTicket, kind: ImpSessionRequest['kind']): boolean {
|
|
377
|
+
let refusal: DaemonError | null;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
refusal = ticket.check();
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
383
|
+
|
|
384
|
+
refusal = new DaemonError(
|
|
385
|
+
'internal',
|
|
386
|
+
`imp ${this.name}'s harness could not check its admission: ${detail}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (refusal === null) {
|
|
391
|
+
this.startSent ||= kind === 'start';
|
|
392
|
+
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
queueMicrotask(() => {
|
|
397
|
+
if (!this.done) {
|
|
398
|
+
this.connection = null;
|
|
399
|
+
|
|
400
|
+
this.applyAdmissionRefusal(refusal);
|
|
401
|
+
}
|
|
254
402
|
});
|
|
255
403
|
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A launch the host's binding no longer admits ends the harness with
|
|
408
|
+
// that refusal; nothing went out to impd.
|
|
409
|
+
private applyAdmissionRefusal(error: unknown): void {
|
|
410
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
411
|
+
|
|
412
|
+
this.startRefusal =
|
|
413
|
+
error instanceof DaemonError
|
|
414
|
+
? error
|
|
415
|
+
: new DaemonError('internal', `imp ${this.name} could not admit the harness: ${detail}`);
|
|
416
|
+
|
|
417
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: `launch refused (${detail})` });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A request without a ticket counts as sent once the port has it; one
|
|
421
|
+
// with a ticket only once its check passes, and the ticket goes back
|
|
422
|
+
// whenever the connection ends or the harness lets go of it.
|
|
423
|
+
private openConnection(request: ImpSessionRequest, ticket?: LaunchTicket): void {
|
|
424
|
+
const gate = ticket === undefined ? undefined : () => this.checkTicket(ticket, request.kind);
|
|
425
|
+
|
|
426
|
+
const connection = this.port.openSession(
|
|
427
|
+
request,
|
|
428
|
+
{
|
|
429
|
+
onStarted: (started) => {
|
|
430
|
+
if (this.connection === connection) {
|
|
431
|
+
this.applyStarted(connection, started);
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
onOutput: (data) => {
|
|
435
|
+
if (this.connection === connection) {
|
|
436
|
+
this.applyOutput(data);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
gate,
|
|
441
|
+
);
|
|
442
|
+
|
|
256
443
|
this.connection = connection;
|
|
444
|
+
this.connectionTicket = ticket ?? null;
|
|
257
445
|
this.started = false;
|
|
446
|
+
this.startSent ||= ticket === undefined && request.kind === 'start';
|
|
258
447
|
this.requestedSize = { cols: request.cols, rows: request.rows };
|
|
259
|
-
void this.waitForOutcome(connection);
|
|
448
|
+
void this.waitForOutcome(connection, ticket ?? null);
|
|
260
449
|
}
|
|
261
450
|
|
|
262
451
|
private applyStarted(connection: ImpSessionConnection, started: ImpSessionStarted): void {
|
|
@@ -294,6 +483,7 @@ export class ImpHarness implements HarnessHandle {
|
|
|
294
483
|
this.failures = 0;
|
|
295
484
|
this.started = true;
|
|
296
485
|
|
|
486
|
+
this.updateStartOutcome('started');
|
|
297
487
|
this.emitAttachment('attached');
|
|
298
488
|
|
|
299
489
|
// impd drops a resize that reaches it before the session starts, so the
|
|
@@ -345,9 +535,14 @@ export class ImpHarness implements HarnessHandle {
|
|
|
345
535
|
}
|
|
346
536
|
}
|
|
347
537
|
|
|
348
|
-
private async waitForOutcome(
|
|
538
|
+
private async waitForOutcome(
|
|
539
|
+
connection: ImpSessionConnection,
|
|
540
|
+
ticket: LaunchTicket | null,
|
|
541
|
+
): Promise<void> {
|
|
349
542
|
const outcome = await connection.outcome;
|
|
350
543
|
|
|
544
|
+
ticket?.release();
|
|
545
|
+
|
|
351
546
|
if (this.connection !== connection || this.done || this.host.isSuspending()) {
|
|
352
547
|
return;
|
|
353
548
|
}
|
|
@@ -450,6 +645,35 @@ export class ImpHarness implements HarnessHandle {
|
|
|
450
645
|
return;
|
|
451
646
|
}
|
|
452
647
|
|
|
648
|
+
if (outcome.code === 'PRECONDITION_FAILED' && isImpdOutdated(outcome.data)) {
|
|
649
|
+
this.startRefusal = new DaemonError(
|
|
650
|
+
'auth_impd_too_old',
|
|
651
|
+
`impd for imp ${this.name} does not honour exec requirements; upgrade it to 0.30.0 or later`,
|
|
652
|
+
{ imp: this.name, execRequire: false },
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: 'impd too old to require the broker' });
|
|
656
|
+
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (outcome.code === 'PRECONDITION_FAILED' && isBrokerNotReady(outcome.data)) {
|
|
661
|
+
const detail =
|
|
662
|
+
isRecord(outcome.data) && typeof outcome.data['detail'] === 'string'
|
|
663
|
+
? outcome.data['detail']
|
|
664
|
+
: 'no detail';
|
|
665
|
+
|
|
666
|
+
this.startRefusal = new DaemonError(
|
|
667
|
+
'broker_not_ready',
|
|
668
|
+
`imp ${this.name} refused to start the harness: its credential broker is not ready (${detail})`,
|
|
669
|
+
{ imp: this.name, detail },
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
this.emitExit({ exitCode: 1, reason: 'ended', detail: `imp broker not ready (${detail})` });
|
|
673
|
+
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
453
677
|
this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
|
|
454
678
|
}
|
|
455
679
|
|
|
@@ -477,7 +701,11 @@ export class ImpHarness implements HarnessHandle {
|
|
|
477
701
|
this.reconnectTimer = null;
|
|
478
702
|
|
|
479
703
|
if (!this.done) {
|
|
480
|
-
this.
|
|
704
|
+
const next = this.startSent
|
|
705
|
+
? this.buildAttach()
|
|
706
|
+
: { ...this.start, cols: this.cols, rows: this.rows };
|
|
707
|
+
|
|
708
|
+
this.openCheckedConnection(next);
|
|
481
709
|
}
|
|
482
710
|
}, wait);
|
|
483
711
|
|
|
@@ -500,6 +728,7 @@ export class ImpHarness implements HarnessHandle {
|
|
|
500
728
|
cols: this.cols,
|
|
501
729
|
rows: this.rows,
|
|
502
730
|
wake: false,
|
|
731
|
+
...(this.require === undefined ? {} : { require: this.require }),
|
|
503
732
|
...(cursor === null
|
|
504
733
|
? {}
|
|
505
734
|
: { resumeFrom: { executionGeneration: cursor.generation, offset: this.highWater } }),
|
|
@@ -516,7 +745,7 @@ export class ImpHarness implements HarnessHandle {
|
|
|
516
745
|
|
|
517
746
|
this.fresh = true;
|
|
518
747
|
|
|
519
|
-
this.
|
|
748
|
+
this.openCheckedConnection(this.buildAttach());
|
|
520
749
|
}
|
|
521
750
|
|
|
522
751
|
// A generation under the session's name other than its own is not the
|
|
@@ -552,6 +781,15 @@ export class ImpHarness implements HarnessHandle {
|
|
|
552
781
|
private emitExit(exit: HarnessExit): void {
|
|
553
782
|
const listeners = [...this.exitListeners];
|
|
554
783
|
|
|
784
|
+
this.updateStartOutcome(
|
|
785
|
+
this.startRefusal ??
|
|
786
|
+
new DaemonError(
|
|
787
|
+
'host_unavailable',
|
|
788
|
+
`imp ${this.name} ended the harness before it started (${exit.detail ?? 'process exited'})`,
|
|
789
|
+
{ provider: 'imp', problem: 'not_started' },
|
|
790
|
+
),
|
|
791
|
+
);
|
|
792
|
+
|
|
555
793
|
this.exitConfirmed = exit.reason === undefined || exit.reason === 'exited';
|
|
556
794
|
|
|
557
795
|
this.stopFollowing();
|
|
@@ -561,6 +799,25 @@ export class ImpHarness implements HarnessHandle {
|
|
|
561
799
|
}
|
|
562
800
|
}
|
|
563
801
|
|
|
802
|
+
// The first outcome counts; each wait settles with it.
|
|
803
|
+
private updateStartOutcome(outcome: 'started' | Readonly<DaemonError>): void {
|
|
804
|
+
if (this.startOutcome !== null) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
this.startOutcome = outcome;
|
|
809
|
+
|
|
810
|
+
for (const waited of this.startWaiters) {
|
|
811
|
+
if (outcome === 'started') {
|
|
812
|
+
waited.resolve();
|
|
813
|
+
} else {
|
|
814
|
+
waited.reject(outcome);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
this.startWaiters.clear();
|
|
819
|
+
}
|
|
820
|
+
|
|
564
821
|
private stopFollowing(): void {
|
|
565
822
|
if (this.reconnectTimer !== null) {
|
|
566
823
|
clearTimeout(this.reconnectTimer);
|
|
@@ -570,8 +827,21 @@ export class ImpHarness implements HarnessHandle {
|
|
|
570
827
|
|
|
571
828
|
this.done = true;
|
|
572
829
|
this.connection = null;
|
|
830
|
+
this.connectionTicket?.release();
|
|
831
|
+
this.connectionTicket = null;
|
|
573
832
|
this.exitConfirmed ??= false;
|
|
574
833
|
|
|
834
|
+
this.updateStartOutcome(
|
|
835
|
+
new DaemonError(
|
|
836
|
+
'host_unavailable',
|
|
837
|
+
`the daemon let go of imp ${this.name}'s harness before it started`,
|
|
838
|
+
{
|
|
839
|
+
provider: 'imp',
|
|
840
|
+
problem: 'not_started',
|
|
841
|
+
},
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
|
|
575
845
|
for (const waiter of this.exitWaiters) {
|
|
576
846
|
clearTimeout(waiter.timer);
|
|
577
847
|
|
|
@@ -586,6 +856,17 @@ export class ImpHarness implements HarnessHandle {
|
|
|
586
856
|
}
|
|
587
857
|
}
|
|
588
858
|
|
|
859
|
+
// impd's refusal of a start whose broker is not ready.
|
|
860
|
+
function isBrokerNotReady(data: unknown): boolean {
|
|
861
|
+
return isRecord(data) && data['reason'] === 'broker_not_ready';
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// The refusal of an exec with requirements against an impd that would
|
|
865
|
+
// ignore them, which impd's client and the harness both give.
|
|
866
|
+
function isImpdOutdated(data: unknown): boolean {
|
|
867
|
+
return isRecord(data) && data['reason'] === 'impd_outdated';
|
|
868
|
+
}
|
|
869
|
+
|
|
589
870
|
// What ended a process impd no longer holds. In the same boot, the kept
|
|
590
871
|
// previous generation gives its exit. Across a cold boot, the first boot
|
|
591
872
|
// after the daemon's own gives the cause; without the daemon's boot among
|
package/src/daemon/imp-port.ts
CHANGED
|
@@ -48,10 +48,13 @@ export interface ImpPort {
|
|
|
48
48
|
|
|
49
49
|
// Opens one connection to a session's terminal, as impd's single
|
|
50
50
|
// attacher: a start runs the command under the session name, or attaches
|
|
51
|
-
// when it already runs; an attach only attaches.
|
|
51
|
+
// when it already runs; an attach only attaches. gate runs once the
|
|
52
|
+
// connection to impd is open, just before the request goes out; false
|
|
53
|
+
// closes the connection with nothing sent.
|
|
52
54
|
readonly openSession: (
|
|
53
55
|
request: ImpSessionRequest,
|
|
54
56
|
handlers: ImpSessionHandlers,
|
|
57
|
+
gate?: () => boolean,
|
|
55
58
|
) => ImpSessionConnection;
|
|
56
59
|
|
|
57
60
|
// Runs a command in the imp to its exit, without a terminal.
|
|
@@ -77,8 +80,19 @@ export interface ImpFeatures {
|
|
|
77
80
|
// A rebound or recreated secret drops its grants and leaves a token's
|
|
78
81
|
// list of grantable secrets behind.
|
|
79
82
|
readonly secretRebind: boolean;
|
|
83
|
+
|
|
84
|
+
// A start may list what must be ready before the command runs, and impd
|
|
85
|
+
// refuses the start without running it when one is not.
|
|
86
|
+
readonly execRequire: boolean;
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
/**
|
|
90
|
+
* What a start can ask impd to have ready before its command runs:
|
|
91
|
+
* `broker` is the credential broker, with its CA installed in the imp and
|
|
92
|
+
* a grant behind its variables.
|
|
93
|
+
*/
|
|
94
|
+
export type ImpExecRequirement = 'broker';
|
|
95
|
+
|
|
82
96
|
type ImpScope = 'read' | 'exec' | 'manage';
|
|
83
97
|
|
|
84
98
|
export interface ImpIdentity {
|
|
@@ -207,6 +221,10 @@ export type ImpSessionRequest =
|
|
|
207
221
|
readonly cols: number;
|
|
208
222
|
readonly rows: number;
|
|
209
223
|
readonly resumeFrom?: ResumeFrom;
|
|
224
|
+
|
|
225
|
+
// impd refuses the start with `PRECONDITION_FAILED` and runs nothing
|
|
226
|
+
// when any of these is not ready.
|
|
227
|
+
readonly require?: readonly ImpExecRequirement[];
|
|
210
228
|
}
|
|
211
229
|
| {
|
|
212
230
|
readonly kind: 'attach';
|
|
@@ -218,6 +236,10 @@ export type ImpSessionRequest =
|
|
|
218
236
|
|
|
219
237
|
// false fails with `INVALID_STATE` instead of booting or waking the imp.
|
|
220
238
|
readonly wake: boolean;
|
|
239
|
+
|
|
240
|
+
// impd refuses the attach with `PRECONDITION_FAILED` when any of these
|
|
241
|
+
// is not ready, or when the process did not start with it required.
|
|
242
|
+
readonly require?: readonly ImpExecRequirement[];
|
|
221
243
|
};
|
|
222
244
|
|
|
223
245
|
export interface ImpSessionHandlers {
|