@zgeoff/atc 2.23.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.
Files changed (36) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +27 -2
  3. package/src/agents/gateway-adapter.ts +21 -3
  4. package/src/cli.ts +20 -8
  5. package/src/client/collect-target-picks.ts +5 -0
  6. package/src/client/spawn-picker.ts +15 -1
  7. package/src/clients.ts +19 -12
  8. package/src/daemon/broker-auth-host.ts +25 -0
  9. package/src/daemon/broker-authority-error.ts +2 -1
  10. package/src/daemon/build-agent-list.ts +16 -3
  11. package/src/daemon/build-scoped-context.ts +2 -0
  12. package/src/daemon/build-target-list.ts +5 -0
  13. package/src/daemon/daemon-connection.ts +52 -2
  14. package/src/daemon/daemon-context.ts +12 -0
  15. package/src/daemon/daemon.ts +58 -1
  16. package/src/daemon/execution-provider.ts +50 -2
  17. package/src/daemon/imp-client-port.ts +71 -23
  18. package/src/daemon/imp-harness.ts +299 -18
  19. package/src/daemon/imp-port.ts +23 -1
  20. package/src/daemon/imp-provider.ts +161 -40
  21. package/src/daemon/local-pty-provider.ts +11 -0
  22. package/src/daemon/restore-fleet.ts +15 -1
  23. package/src/daemon/runtime-auth-binder.ts +1086 -0
  24. package/src/daemon/sessions.ts +565 -25
  25. package/src/daemon/verify-broker-authority.ts +10 -5
  26. package/src/gateway.ts +183 -0
  27. package/src/mcp/mcp-tools.ts +2 -0
  28. package/src/mcp/require-daemon-features.ts +1 -0
  29. package/src/parse-gateway-state-dir.ts +94 -0
  30. package/src/protocol/daemon-features.ts +4 -0
  31. package/src/protocol/protocol.ts +16 -0
  32. package/src/protocol/request-param-schemas.ts +5 -0
  33. package/src/run-gateway.ts +93 -0
  34. package/src/shared/collect-gateways.ts +1 -22
  35. package/src/shared/is-broker-variable.ts +24 -0
  36. package/src/store/state-store.ts +11 -0
@@ -1,3 +1,6 @@
1
+ import type { DaemonError } from '../protocol/daemon-error';
2
+ import type { BrokerAuthHost } from './broker-auth-host';
3
+
1
4
  /**
2
5
  * The host a session's harness runs on: it starts a process in a
3
6
  * pseudo-terminal and, as its capabilities declare, unpacks files into its
@@ -22,6 +25,11 @@ export interface ExecutionProvider {
22
25
  // host has none. Absent on the daemon's own machine.
23
26
  readonly guest?: GuestLayout;
24
27
 
28
+ // How a harness here can take its credential from impd's broker instead
29
+ // of holding it. Absent on a host with no broker, which never starts a
30
+ // session that needs one.
31
+ readonly brokerAuth?: BrokerAuthHost;
32
+
25
33
  // Readies the host a harness is about to start on: a remote host is
26
34
  // created when missing, woken when asleep, and held awake while its
27
35
  // harnesses run. Rejects with the refusal before any harness starts.
@@ -41,8 +49,12 @@ export interface ExecutionProvider {
41
49
 
42
50
  // Puts a host to sleep with every harness on it kept inside, so a revive
43
51
  // finds each one as it was. Rejects with `host_leased` when another owner
44
- // keeps the host awake, and leaves the host as it was then.
45
- readonly suspendHost: (host: string) => Promise<void>;
52
+ // keeps the host awake, and leaves the host as it was then. A sleep runs
53
+ // after any readying of the host before it, and a readying waits for it.
54
+ // isIdle makes it a sleep of an idle host only: it is checked when the
55
+ // sleep starts and again just before the host sleeps, and a host that a
56
+ // harness or a readying keeps busy stays awake with `host_unavailable`.
57
+ readonly suspendHost: (host: string, isIdle?: () => boolean) => Promise<void>;
46
58
 
47
59
  // Deletes a host and everything on it, harnesses included. Nothing brings
48
60
  // a destroyed host back.
@@ -65,6 +77,10 @@ export interface HostRequest {
65
77
  // Whether the harness about to start needs atc inside the host, which a
66
78
  // provider that ships its own binary installs when missing.
67
79
  readonly installATC?: boolean;
80
+
81
+ // Whether the daemon has nothing running or starting on the host, which
82
+ // the provider checks before it gives the host's lease back on its own.
83
+ readonly isIdle?: () => boolean;
68
84
  }
69
85
 
70
86
  export interface GuestLayout {
@@ -132,6 +148,20 @@ export interface HarnessSpec {
132
148
  readonly cols: number;
133
149
  readonly rows: number;
134
150
 
151
+ // Whether the harness must not start unless the host's credential broker
152
+ // is ready: the host refuses the start and runs nothing otherwise, on
153
+ // every start, a revive's included.
154
+ readonly requireBroker?: boolean;
155
+
156
+ // Admits each start or attach of a harness that requires the broker by
157
+ // calling send, which hands the request to the host, or rejects with the
158
+ // refusal that ends the harness instead, sending nothing. send gets the
159
+ // admission's ticket.
160
+ readonly admit?: (
161
+ kind: 'start' | 'attach',
162
+ send: (ticket: LaunchTicket) => void,
163
+ ) => Promise<void>;
164
+
135
165
  // Takes each connection a process of the harness opens to the daemon. A
136
166
  // remote provider relays them from a socket inside the host that serves
137
167
  // this harness alone, and points the harness's ATC_SOCKET at it.
@@ -143,6 +173,18 @@ export interface HarnessSpec {
143
173
  * lines: each line the process writes arrives whole, and each line the
144
174
  * daemon writes reaches the process in order.
145
175
  */
176
+ /**
177
+ * One admitted request of a harness behind the broker. check runs just
178
+ * before the request goes out and returns the refusal that stops it
179
+ * unsent, or null to let it go; release gives the admission up once its
180
+ * connection ends without the request going out. Each runs at most once
181
+ * to any effect.
182
+ */
183
+ export interface LaunchTicket {
184
+ readonly check: () => DaemonError | null;
185
+ readonly release: () => void;
186
+ }
187
+
146
188
  export interface HarnessRelay {
147
189
  readonly onLine: (listener: (line: string) => void) => void;
148
190
  readonly onClose: (listener: () => void) => void;
@@ -176,6 +218,12 @@ export interface HarnessHandle {
176
218
  // a provider that can send one; absent on a provider that cannot.
177
219
  readonly killForced?: () => void;
178
220
 
221
+ // Resolves once the harness's process has started, or a running one was
222
+ // attached, and rejects with the refusal when the host ends the harness
223
+ // or the daemon lets go of it first. Absent on a provider whose harness
224
+ // starts at once.
225
+ readonly waitForStart?: () => Promise<void>;
226
+
179
227
  // Resolves true once the harness's process has exited, and false when the
180
228
  // wait runs out first or the harness stops being followed without an
181
229
  // exit, such as a host that went to sleep with the process inside.
@@ -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) => new WebSocket(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.openConnection(start);
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.openConnection({ ...start, cols: this.cols, rows: this.rows });
296
+ this.openCheckedConnection({ ...start, cols: this.cols, rows: this.rows });
239
297
  }
240
298
  }
241
299
 
242
- private openConnection(request: ImpSessionRequest): void {
243
- const connection = this.port.openSession(request, {
244
- onStarted: (started) => {
245
- if (this.connection === connection) {
246
- this.applyStarted(connection, started);
247
- }
248
- },
249
- onOutput: (data) => {
250
- if (this.connection === connection) {
251
- this.applyOutput(data);
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(connection: ImpSessionConnection): Promise<void> {
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.openConnection(this.buildAttach());
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.openConnection(this.buildAttach());
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