@zgeoff/atc 3.2.0 → 3.3.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 (68) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -3
  3. package/src/build-own-daemon-process.ts +16 -0
  4. package/src/build-restart-failure.ts +24 -0
  5. package/src/cli.ts +96 -0
  6. package/src/client/boot-daemon.ts +5 -32
  7. package/src/collect-restart-env.ts +18 -0
  8. package/src/collect-restart-plan.ts +157 -0
  9. package/src/daemon/daemon.ts +7 -3
  10. package/src/daemon-process.ts +12 -0
  11. package/src/find-restart-unit.ts +45 -0
  12. package/src/format-restart-preflight.ts +74 -0
  13. package/src/format-restart-report.ts +45 -0
  14. package/src/is-daemon-command-line.ts +33 -0
  15. package/src/is-daemon-process.ts +33 -0
  16. package/src/parse-daemon-flags.ts +33 -0
  17. package/src/parse-mismatch-build.ts +19 -0
  18. package/src/parse-restart-result.ts +65 -0
  19. package/src/parse-systemd-unit.ts +25 -0
  20. package/src/read-daemon-process.ts +50 -0
  21. package/src/read-fleet-snapshot.ts +37 -0
  22. package/src/read-restart-result.ts +17 -0
  23. package/src/read-stored-fleet-file.ts +74 -0
  24. package/src/read-stored-rows.ts +36 -0
  25. package/src/remove-restart-result.ts +11 -0
  26. package/src/restart-daemon.ts +149 -0
  27. package/src/restart-plan.ts +33 -0
  28. package/src/run-daemon-restart-worker.ts +77 -0
  29. package/src/run-daemon-restart.ts +228 -0
  30. package/src/run-systemctl.ts +33 -0
  31. package/src/shared/build-atc-command.ts +13 -0
  32. package/src/shared/config.ts +5 -0
  33. package/src/shared/find-pid-file-pid.ts +15 -0
  34. package/src/shared/get-build.ts +4 -3
  35. package/src/shared/is-process-alive.ts +21 -0
  36. package/src/shared/spawn-atc-detached.ts +34 -0
  37. package/src/start-replacement-daemon.ts +31 -0
  38. package/src/stop-daemon-process.ts +46 -0
  39. package/src/store/state-store.ts +2 -2
  40. package/src/test-utils/can-bind-addresses.ts +19 -0
  41. package/src/test-utils/create-migrated-state-db.ts +38 -0
  42. package/src/test-utils/env-originals.ts +6 -0
  43. package/src/test-utils/fixture-dir-provider.ts +131 -0
  44. package/src/test-utils/fixture-imp-port.ts +1692 -0
  45. package/src/test-utils/get-agent-entry.ts +17 -0
  46. package/src/test-utils/get-gateway-config.ts +18 -0
  47. package/src/test-utils/isolate-home.ts +60 -0
  48. package/src/test-utils/read-json-record.ts +20 -0
  49. package/src/test-utils/remove-env-overrides.ts +18 -0
  50. package/src/test-utils/run-legacy-daemon.ts +38 -0
  51. package/src/test-utils/run-listener-daemon.ts +51 -0
  52. package/src/test-utils/run-mcp-authorization.ts +118 -0
  53. package/src/test-utils/run-source-daemon.ts +110 -0
  54. package/src/test-utils/setup-fake-systemd.ts +125 -0
  55. package/src/test-utils/setup-mcp-http.ts +126 -0
  56. package/src/test-utils/setup-temp-dir.ts +33 -0
  57. package/src/test-utils/spawn-named-session.ts +27 -0
  58. package/src/test-utils/start-cut-proxy.ts +175 -0
  59. package/src/test-utils/start-git-http-server.ts +102 -0
  60. package/src/test-utils/start-legacy-daemon.ts +111 -0
  61. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  62. package/src/test-utils/update-env.ts +19 -0
  63. package/src/test-utils/wait-for.ts +38 -0
  64. package/src/verify-restored-fleet.ts +155 -0
  65. package/src/wait-for-replacement.ts +112 -0
  66. package/src/write-restart-result.ts +17 -0
  67. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  68. /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
@@ -0,0 +1,1692 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { rmSync } from 'node:fs';
3
+ import { posix } from 'node:path';
4
+ import { spawn } from 'bun-pty';
5
+ import type { IPty } from 'bun-pty';
6
+ import type {
7
+ ColdBoot,
8
+ ColdBootCause,
9
+ ImpCommand,
10
+ ImpCommandResult,
11
+ ImpCreateSpec,
12
+ ImpFeatures,
13
+ ImpIdentity,
14
+ ImpLease,
15
+ ImpPort,
16
+ ImpRelayConnection,
17
+ ImpReverseForward,
18
+ ImpSecret,
19
+ ImpSecretRule,
20
+ ImpSessionConnection,
21
+ ImpSessionHandlers,
22
+ ImpSessionOutcome,
23
+ ImpSessionRequest,
24
+ ImpState,
25
+ ImpView,
26
+ PreviousGeneration,
27
+ ResumeResult,
28
+ } from '../daemon/imp-port';
29
+ import { ImpPortError } from '../daemon/imp-port-error';
30
+ import { isImpNameAllowed } from '../daemon/is-imp-name-allowed';
31
+ import { isBrokerVariable } from '../shared/is-broker-variable';
32
+
33
+ // impd keeps exactly this many bytes of each generation's output.
34
+ const RING_BYTES = 262_144;
35
+
36
+ // The mode bytes a fresh attach sends ahead of a ring that has wrapped.
37
+ const PRELUDE = '\u001B[0m';
38
+
39
+ // The PATH every fixture process runs with: a guest's own, never the
40
+ // daemon's.
41
+ const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
42
+
43
+ /**
44
+ * An in-process stand-in for impd behind the imp port. Each imp is a set of
45
+ * real `bun-pty` processes on this machine; a sleeping imp stops them with
46
+ * SIGSTOP and a wake continues them, so a memory wake keeps each process
47
+ * and its generation. Every session keeps an exact 262144-byte ring with
48
+ * offsets, a fresh attach skips to the next line and sends a mode prelude,
49
+ * and impd's refusals carry its codes and data: `LEASED`, `LEASE_NOT_HELD`,
50
+ * `NO_SESSION`, `INVALID_STATE`, `INVALID_RESUME`, `NOT_FOUND`, `CONFLICT`,
51
+ * and `FORBIDDEN`. Leases belong to principals; the port acts as
52
+ * `principal`, and a test adds other owners' leases, cold boots, and
53
+ * dropped sockets through the controls. Grants follow impd 0.27: the
54
+ * caller's identity must reach the imp and, under imp patterns, list the
55
+ * secret as grantable; a grant is idempotent, one secret per host, and a
56
+ * destroyed imp or a rebound or removed secret takes its grants with it. A
57
+ * start or an attach that requires the broker is refused with
58
+ * `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
59
+ * while the broker fails or the imp holds no grant, when the start sets a
60
+ * broker variable, and when it would join a process that started without
61
+ * the broker required.
62
+ */
63
+ // A hold on the commands whose argv holds its text: entered resolves with
64
+ // the argv of the first command it holds, and stop lets every command it
65
+ // holds run.
66
+ interface FixtureCommandHold {
67
+ readonly entered: Promise<string>;
68
+ readonly stop: () => void;
69
+ }
70
+
71
+ export class FixtureImpPort implements ImpPort {
72
+ // Every port call, in order, as `<call> <imp> [<detail>]`.
73
+ readonly calls: string[] = [];
74
+
75
+ // Every session request the port received, in order.
76
+ readonly sessionRequests: ImpSessionRequest[] = [];
77
+
78
+ features: ImpFeatures = {
79
+ sessionOffsets: true,
80
+ leases: true,
81
+ grantableTokens: true,
82
+ secretRebind: true,
83
+ execRequire: true,
84
+ oauthSecrets: true,
85
+ };
86
+
87
+ // Who the port calls impd as.
88
+ private identity: ImpIdentity = {
89
+ kind: 'token',
90
+ name: 'atc',
91
+ scope: 'manage',
92
+ imps: ['atc-*'],
93
+ grantable: [],
94
+ };
95
+
96
+ // Grantable secrets rebound or removed since the identity was set, which
97
+ // its list no longer covers, as a secret's new generation leaves a
98
+ // token's list behind.
99
+ private readonly staleGrantable = new Set<string>();
100
+
101
+ // The secrets impd holds, by name; which imps hold each is the grants'.
102
+ private readonly secrets = new Map<string, Omit<ImpSecret, 'imps'>>();
103
+
104
+ // What a session's agent carries: `none` replays on every connection and
105
+ // ignores resumes.
106
+ continuity: 'offsets' | 'none' = 'offsets';
107
+
108
+ // How many bytes before the asked offset an exact resume starts, as
109
+ // impd's at-least-once delivery allows.
110
+ resumeOverlap = 0;
111
+
112
+ // Session answers held back while a test lets output pile up, or null
113
+ // when answers go out at once.
114
+ private held: (() => void)[] | null = null;
115
+
116
+ // How many of the next session requests impd drops before it answers,
117
+ // and the close code it drops them with.
118
+ private drops = { count: 0, closeCode: 1011 };
119
+
120
+ // Lease acquisitions that go through before the next one fails, and
121
+ // impd's code for that failure, or null for none.
122
+ private acquireFailure: { skip: number; readonly code: string } | null = null;
123
+
124
+ // A refusal the next session request gets instead of an answer, with the
125
+ // message it carries when it has one of its own.
126
+ private nextFailure: {
127
+ readonly code: string | null;
128
+ readonly message?: string;
129
+ readonly data: unknown;
130
+ } | null = null;
131
+
132
+ // Feature reads still to fail as an unreachable impd before they answer.
133
+ private featureFailures = 0;
134
+
135
+ // Session connections whose opening still throws before it returns, as
136
+ // an invalid authorization header makes the socket's constructor throw.
137
+ private openFailures = 0;
138
+
139
+ // Session connections still to fail their upgrade, ending unreachable
140
+ // before they open and before any gate runs.
141
+ private upgradeFailures = 0;
142
+
143
+ // The connections still opening while upgrades are held, each sending
144
+ // its request once it opens; null while connections open at once.
145
+ private upgrades: (() => void)[] | null = null;
146
+
147
+ // The lease releases wait for this hold to end, while one is held.
148
+ private releaseHold: PromiseWithResolvers<void> | null = null;
149
+
150
+ // The lease acquisitions wait for this hold to end, while one is held.
151
+ private leaseHold: PromiseWithResolvers<void> | null = null;
152
+
153
+ // The directory a relative or missing working directory resolves
154
+ // against inside every imp, as a guest's home does; null for the test's
155
+ // own working directory.
156
+ private homeDir: string | null = null;
157
+
158
+ // Commands whose argv holds this text wait for the hold to stop, while
159
+ // one is held.
160
+ private commandHold: {
161
+ readonly match: string;
162
+ readonly entered: PromiseWithResolvers<string>;
163
+ readonly done: PromiseWithResolvers<void>;
164
+ } | null = null;
165
+
166
+ // Commands whose argv holds this text exit 1 without running, or null.
167
+ private commandFailure: string | null = null;
168
+
169
+ // impd's code for every imp destroy while destroys fail, or null.
170
+ private destroyFailure: string | null = null;
171
+
172
+ // impd's code for every grant removal while removals fail, or null.
173
+ private grantRemovalFailure: string | null = null;
174
+
175
+ // Whether every reverse forward closes each new guest connection at once.
176
+ private refusingRelays = false;
177
+
178
+ // Whether the broker fails in every imp, as a CA that did not install
179
+ // leaves it.
180
+ private brokerFailing = false;
181
+
182
+ // Whether every relayed connection drops what the guest writes, while
183
+ // what the daemon writes still reaches the guest.
184
+ private droppingGuestBytes = false;
185
+
186
+ private readonly principal: string;
187
+
188
+ private readonly imps = new Map<string, FixtureImp>();
189
+
190
+ private readonly forwards = new Set<{ stop: () => void; stopRelays: () => void }>();
191
+
192
+ constructor(principal = 'token:atc') {
193
+ this.principal = principal;
194
+ }
195
+
196
+ readFeatures(): Promise<ImpFeatures> {
197
+ this.calls.push('system.info');
198
+
199
+ if (this.featureFailures > 0) {
200
+ this.featureFailures -= 1;
201
+
202
+ return Promise.reject(new ImpPortError('UNREACHABLE', 'impd did not answer'));
203
+ }
204
+
205
+ return Promise.resolve(this.features);
206
+ }
207
+
208
+ readIdentity(): Promise<ImpIdentity> {
209
+ this.calls.push('tokens.whoami');
210
+
211
+ return Promise.resolve(this.identity);
212
+ }
213
+
214
+ readSecrets(): Promise<readonly ImpSecret[]> {
215
+ this.calls.push('secrets.list');
216
+
217
+ return Promise.resolve(
218
+ [...this.secrets.values()].map((secret): ImpSecret => {
219
+ const imps = [...this.imps.values()]
220
+ .filter((imp) => imp.grants.has(secret.name))
221
+ .map((imp) => imp.name);
222
+
223
+ const listed = { name: secret.name, kind: secret.kind, rules: secret.rules, imps };
224
+
225
+ return secret.oauth === undefined ? listed : Object.assign(listed, { oauth: secret.oauth });
226
+ }),
227
+ );
228
+ }
229
+
230
+ readGrants(name: string): Promise<readonly string[]> {
231
+ this.calls.push(`grants.list ${name}`);
232
+
233
+ const imp = this.imps.get(name);
234
+
235
+ if (imp === undefined) {
236
+ return Promise.reject(buildNotFound(name));
237
+ }
238
+
239
+ return Promise.resolve([...imp.grants]);
240
+ }
241
+
242
+ createGrant(name: string, secret: string): Promise<void> {
243
+ this.calls.push(`grants.add ${name} ${secret}`);
244
+
245
+ const refusal = this.findGrantRefusal(name, secret);
246
+
247
+ if (refusal !== null) {
248
+ return Promise.reject(refusal);
249
+ }
250
+
251
+ const imp = this.getImp(name);
252
+
253
+ const hosts = new Set(this.secrets.get(secret)?.rules.map((rule) => rule.host));
254
+
255
+ const clash = [...imp.grants].find(
256
+ (held) =>
257
+ held !== secret &&
258
+ (this.secrets.get(held)?.rules ?? []).some((rule) => hosts.has(rule.host)),
259
+ );
260
+
261
+ if (clash !== undefined) {
262
+ return Promise.reject(
263
+ new ImpPortError('CONFLICT', `grant ${clash} covers a host of ${secret}`, {
264
+ kind: 'grant',
265
+ name: `${name}/${clash}`,
266
+ }),
267
+ );
268
+ }
269
+
270
+ imp.grants.add(secret);
271
+
272
+ return Promise.resolve();
273
+ }
274
+
275
+ removeGrant(name: string, secret: string): Promise<boolean> {
276
+ this.calls.push(`grants.delete ${name} ${secret}`);
277
+
278
+ if (this.grantRemovalFailure !== null) {
279
+ return Promise.reject(
280
+ new ImpPortError(this.grantRemovalFailure, 'impd did not remove the grant'),
281
+ );
282
+ }
283
+
284
+ const refusal = this.findGrantRefusal(name, secret);
285
+
286
+ if (refusal !== null) {
287
+ return Promise.reject(refusal);
288
+ }
289
+
290
+ return Promise.resolve(this.getImp(name).grants.delete(secret));
291
+ }
292
+
293
+ readImp(name: string): Promise<ImpView | null> {
294
+ this.calls.push(`imps.get ${name}`);
295
+
296
+ const imp = this.imps.get(name);
297
+ const view = imp === undefined ? null : this.buildView(imp);
298
+
299
+ return Promise.resolve(view);
300
+ }
301
+
302
+ createImp(spec: ImpCreateSpec): Promise<ImpView> {
303
+ this.calls.push(`imps.create ${spec.name}`);
304
+
305
+ if (this.imps.has(spec.name)) {
306
+ return Promise.reject(
307
+ new ImpPortError('CONFLICT', `imp ${spec.name} exists`, { kind: 'imp', name: spec.name }),
308
+ );
309
+ }
310
+
311
+ const imp: FixtureImp = {
312
+ id: randomUUID(),
313
+ name: spec.name,
314
+ state: 'stopped',
315
+ bootId: '',
316
+ coldBoots: [],
317
+ leases: new Map(),
318
+ sessions: new Map(),
319
+ previous: new Map(),
320
+ grants: new Set(),
321
+ };
322
+
323
+ this.imps.set(spec.name, imp);
324
+ this.bootCold(imp, 'start');
325
+
326
+ return Promise.resolve(this.buildView(imp));
327
+ }
328
+
329
+ acquireLease(name: string, label: string, ttlSeconds: number): Promise<ImpLease> {
330
+ this.calls.push(`leases.acquire ${name} ${label}`);
331
+
332
+ return this.leaseHold === null
333
+ ? this.applyLease(name, label, ttlSeconds)
334
+ : this.waitForLease(name, label, ttlSeconds);
335
+ }
336
+
337
+ private async waitForLease(name: string, label: string, ttlSeconds: number): Promise<ImpLease> {
338
+ await this.leaseHold?.promise;
339
+
340
+ return this.applyLease(name, label, ttlSeconds);
341
+ }
342
+
343
+ private applyLease(name: string, label: string, ttlSeconds: number): Promise<ImpLease> {
344
+ const failure = this.acquireFailure;
345
+
346
+ if (failure !== null) {
347
+ if (failure.skip === 0) {
348
+ this.acquireFailure = null;
349
+
350
+ return Promise.reject(
351
+ new ImpPortError(failure.code, `impd refused the lease (${failure.code})`),
352
+ );
353
+ }
354
+
355
+ failure.skip -= 1;
356
+ }
357
+
358
+ if (label === 'hold' || ttlSeconds < 10 || ttlSeconds > 3600) {
359
+ return Promise.reject(new ImpPortError('BAD_REQUEST', 'the label or ttl is not allowed'));
360
+ }
361
+
362
+ const imp = this.imps.get(name);
363
+
364
+ if (imp === undefined) {
365
+ return Promise.reject(buildNotFound(name));
366
+ }
367
+
368
+ this.updateAwake(imp);
369
+
370
+ const until = Date.now() + ttlSeconds * 1000;
371
+
372
+ imp.leases.set(`${this.principal}\u0000${label}`, { principal: this.principal, label, until });
373
+
374
+ return Promise.resolve(buildLease(name, this.principal, label, until));
375
+ }
376
+
377
+ renewLease(name: string, label: string, ttlSeconds: number): Promise<ImpLease> {
378
+ this.calls.push(`leases.renew ${name} ${label}`);
379
+
380
+ const imp = this.imps.get(name);
381
+ const key = `${this.principal}\u0000${label}`;
382
+ const lease = imp?.leases.get(key);
383
+
384
+ if (imp === undefined || lease === undefined || lease.until <= Date.now()) {
385
+ return Promise.reject(new ImpPortError('LEASE_NOT_HELD', 'the caller holds no such lease'));
386
+ }
387
+
388
+ const until = Date.now() + ttlSeconds * 1000;
389
+
390
+ imp.leases.set(key, { ...lease, until });
391
+
392
+ return Promise.resolve(buildLease(name, this.principal, label, until));
393
+ }
394
+
395
+ releaseLease(name: string, label: string): Promise<boolean> {
396
+ this.calls.push(`leases.release ${name} ${label}`);
397
+
398
+ return this.releaseHold === null
399
+ ? this.applyRelease(name, label)
400
+ : this.waitForRelease(name, label);
401
+ }
402
+
403
+ private async waitForRelease(name: string, label: string): Promise<boolean> {
404
+ await this.releaseHold?.promise;
405
+
406
+ return this.applyRelease(name, label);
407
+ }
408
+
409
+ private applyRelease(name: string, label: string): Promise<boolean> {
410
+ const imp = this.imps.get(name);
411
+
412
+ if (imp === undefined) {
413
+ return Promise.reject(buildNotFound(name));
414
+ }
415
+
416
+ return Promise.resolve(imp.leases.delete(`${this.principal}\u0000${label}`));
417
+ }
418
+
419
+ suspendImp(name: string): Promise<void> {
420
+ this.calls.push(`imps.sleep ${name}`);
421
+
422
+ const imp = this.imps.get(name);
423
+
424
+ if (imp === undefined) {
425
+ return Promise.reject(buildNotFound(name));
426
+ }
427
+
428
+ const live = [...imp.leases.values()].filter((lease) => lease.until > Date.now());
429
+
430
+ if (imp.state === 'running' && live.length > 0) {
431
+ const own = live.filter((lease) => lease.principal === this.principal);
432
+ const others = live.filter((lease) => lease.principal !== this.principal);
433
+
434
+ const otherPrincipals = new Set(others.map((lease) => lease.principal));
435
+
436
+ return Promise.reject(
437
+ new ImpPortError('LEASED', 'the imp is leased', {
438
+ leases: own.map((lease) => buildLease(name, lease.principal, lease.label, lease.until)),
439
+ otherCount: otherPrincipals.size,
440
+ }),
441
+ );
442
+ }
443
+
444
+ this.updateAsleep(imp);
445
+
446
+ return Promise.resolve();
447
+ }
448
+
449
+ destroyImp(name: string): Promise<void> {
450
+ this.calls.push(`imps.destroy ${name}`);
451
+
452
+ if (this.destroyFailure !== null) {
453
+ return Promise.reject(
454
+ new ImpPortError(this.destroyFailure, `impd could not destroy ${name}`),
455
+ );
456
+ }
457
+
458
+ const imp = this.imps.get(name);
459
+
460
+ if (imp === undefined) {
461
+ return Promise.reject(buildNotFound(name));
462
+ }
463
+
464
+ for (const proc of imp.sessions.values()) {
465
+ proc.connection?.finish({ kind: 'closed', reason: 'imp destroyed', closeCode: 1000 });
466
+ proc.connection = null;
467
+ proc.exited ??= { code: null };
468
+
469
+ tryKill(proc.pty, 'SIGKILL');
470
+ }
471
+
472
+ this.imps.delete(name);
473
+
474
+ return Promise.resolve();
475
+ }
476
+
477
+ openSession(
478
+ request: ImpSessionRequest,
479
+ handlers: ImpSessionHandlers,
480
+ gate?: () => boolean,
481
+ ): ImpSessionConnection {
482
+ if (this.openFailures > 0) {
483
+ this.openFailures -= 1;
484
+ throw new TypeError('the authorization header is invalid');
485
+ }
486
+
487
+ const outcome = Promise.withResolvers<ImpSessionOutcome>();
488
+
489
+ const connection: FixtureConnection = {
490
+ handlers,
491
+ sent: 0,
492
+ finished: false,
493
+ process: null,
494
+ finish: (result) => {
495
+ if (connection.finished) {
496
+ return;
497
+ }
498
+
499
+ connection.finished = true;
500
+
501
+ if (connection.process?.connection === connection) {
502
+ connection.process.connection = null;
503
+ }
504
+
505
+ outcome.resolve(result);
506
+ },
507
+ };
508
+
509
+ // The request reaches impd once the connection opens, after the gate
510
+ // lets it go; a closed gate sends nothing.
511
+ const sendToImpd = () => {
512
+ if (gate !== undefined && !tryPassGate(gate)) {
513
+ connection.finish({ kind: 'closed', reason: 'closed before sending', closeCode: 1000 });
514
+
515
+ return;
516
+ }
517
+
518
+ this.calls.push(`exec.${request.kind} ${request.name} ${request.session}`);
519
+ this.sessionRequests.push(request);
520
+
521
+ // impd answers over the network: nothing reaches the caller before
522
+ // openSession returns.
523
+ setTimeout(() => {
524
+ if (this.held === null) {
525
+ this.answerSession(request, connection);
526
+ } else {
527
+ this.held.push(() => {
528
+ this.answerSession(request, connection);
529
+ });
530
+ }
531
+ }, 0);
532
+ };
533
+
534
+ if (this.upgradeFailures > 0) {
535
+ this.upgradeFailures -= 1;
536
+
537
+ setTimeout(() => {
538
+ connection.finish({ kind: 'unreachable', detail: 'the upgrade failed' });
539
+ }, 0);
540
+ } else if (this.upgrades === null) {
541
+ sendToImpd();
542
+ } else {
543
+ this.upgrades.push(sendToImpd);
544
+ }
545
+
546
+ return {
547
+ outcome: outcome.promise,
548
+ write: (data) => {
549
+ if (!connection.finished && connection.process !== null) {
550
+ connection.process.pty.write(new TextDecoder().decode(data));
551
+ }
552
+ },
553
+ resize: (cols, rows) => {
554
+ if (!connection.finished && connection.process !== null) {
555
+ connection.process.pty.resize(cols, rows);
556
+ }
557
+ },
558
+ sendSignal: (signal) => {
559
+ if (!connection.finished && connection.process !== null) {
560
+ tryKill(connection.process.pty, 'SIGCONT');
561
+ tryKill(connection.process.pty, toSignal(signal));
562
+ }
563
+ },
564
+ close: () => {
565
+ connection.finish({ kind: 'closed', reason: 'closed by the client', closeCode: 1000 });
566
+ },
567
+ };
568
+ }
569
+
570
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
571
+ async runCommand(name: string, command: ImpCommand): Promise<ImpCommandResult> {
572
+ this.calls.push(`exec.run ${name} ${command.argv.join(' ')}`);
573
+
574
+ const imp = this.imps.get(name);
575
+
576
+ if (imp === undefined) {
577
+ throw buildNotFound(name);
578
+ }
579
+
580
+ if (imp.state !== 'running') {
581
+ throw new ImpPortError('INVALID_STATE', `imp ${name} is ${imp.state}`, {
582
+ state: imp.state,
583
+ allowed: ['running'],
584
+ });
585
+ }
586
+
587
+ const line = command.argv.join(' ');
588
+ const hold = this.commandHold;
589
+
590
+ if (hold !== null && line.includes(hold.match)) {
591
+ hold.entered.resolve(line);
592
+
593
+ await hold.done.promise;
594
+ }
595
+
596
+ if (this.commandFailure !== null && line.includes(this.commandFailure)) {
597
+ const encoder = new TextEncoder();
598
+
599
+ return { code: 1, stdout: new Uint8Array(0), stderr: encoder.encode('the command failed\n') };
600
+ }
601
+
602
+ const cwd = this.resolveGuestCwd(command.cwd);
603
+
604
+ const proc = Bun.spawn([...command.argv], {
605
+ ...(cwd === undefined ? {} : { cwd }),
606
+ env: { PATH: GUEST_PATH, ...(this.homeDir === null ? {} : { HOME: this.homeDir }) },
607
+ stdin: command.stdin ?? 'ignore',
608
+ stdout: 'pipe',
609
+ stderr: 'pipe',
610
+ });
611
+
612
+ const [stdout, stderr, code] = await Promise.all([
613
+ new Response(proc.stdout).bytes(),
614
+ new Response(proc.stderr).bytes(),
615
+ proc.exited,
616
+ ]);
617
+
618
+ return { code, stdout, stderr };
619
+ }
620
+
621
+ openReverseForward(
622
+ name: string,
623
+ guestPath: string,
624
+ onConnection: (connection: ImpRelayConnection) => void,
625
+ ): ImpReverseForward {
626
+ this.calls.push(`reverse ${name} ${guestPath}`);
627
+
628
+ const server = Bun.listen<FixtureRelay>({
629
+ unix: guestPath,
630
+ socket: {
631
+ open: (socket) => {
632
+ const relay: FixtureRelay = {
633
+ dataListeners: [],
634
+ closeListeners: [],
635
+ unsent: new Uint8Array(0),
636
+ roomWaiters: [],
637
+ };
638
+
639
+ socket.data = relay;
640
+
641
+ if (this.refusingRelays) {
642
+ socket.end();
643
+
644
+ return;
645
+ }
646
+
647
+ relays.add(socket);
648
+
649
+ onConnection({
650
+ onData: (listener) => {
651
+ relay.dataListeners.push(listener);
652
+ },
653
+ onClose: (listener) => {
654
+ relay.closeListeners.push(listener);
655
+ },
656
+
657
+ // A socket write takes what fits and drops the rest, so the
658
+ // remainder waits for the next drain.
659
+ write: async (data) => {
660
+ relay.unsent = mergeTail(relay.unsent, data, Number.POSITIVE_INFINITY);
661
+ relay.unsent = relay.unsent.subarray(socket.write(relay.unsent));
662
+
663
+ if (relay.unsent.length > 0) {
664
+ const room = Promise.withResolvers<void>();
665
+
666
+ relay.roomWaiters.push(room.resolve);
667
+
668
+ await room.promise;
669
+ }
670
+ },
671
+ close: () => {
672
+ socket.end();
673
+ },
674
+ });
675
+ },
676
+ data: (socket, buf) => {
677
+ if (this.droppingGuestBytes) {
678
+ return;
679
+ }
680
+
681
+ for (const listener of socket.data.dataListeners) {
682
+ listener(new Uint8Array(buf));
683
+ }
684
+ },
685
+ drain(socket) {
686
+ const relay = socket.data;
687
+
688
+ relay.unsent = relay.unsent.subarray(socket.write(relay.unsent));
689
+
690
+ if (relay.unsent.length === 0) {
691
+ for (const resolve of relay.roomWaiters.splice(0)) {
692
+ resolve();
693
+ }
694
+ }
695
+ },
696
+ close: (socket) => {
697
+ relays.delete(socket);
698
+
699
+ for (const resolve of socket.data.roomWaiters.splice(0)) {
700
+ resolve();
701
+ }
702
+
703
+ for (const listener of socket.data.closeListeners) {
704
+ listener();
705
+ }
706
+ },
707
+ error() {},
708
+ },
709
+ });
710
+
711
+ const relays = new Set<{ readonly end: () => void }>();
712
+
713
+ const forward = {
714
+ stop: () => {
715
+ server.stop(true);
716
+
717
+ rmSync(guestPath, { force: true });
718
+
719
+ this.forwards.delete(forward);
720
+ },
721
+ stopRelays: () => {
722
+ for (const relay of relays) {
723
+ relay.end();
724
+ }
725
+ },
726
+ };
727
+
728
+ this.forwards.add(forward);
729
+
730
+ return { listening: Promise.resolve(), stop: forward.stop };
731
+ }
732
+
733
+ /**
734
+ * Gives impd the features of a daemon from before grantable tokens,
735
+ * secret rebinds and exec requirements, which has none of those flags.
736
+ */
737
+ setOldDaemonFeatures(): void {
738
+ this.features = {
739
+ sessionOffsets: true,
740
+ leases: true,
741
+ grantableTokens: false,
742
+ secretRebind: false,
743
+ execRequire: false,
744
+ oauthSecrets: false,
745
+ };
746
+ }
747
+
748
+ /**
749
+ * Sets who the port calls impd as, as a new token in the token file
750
+ * does; its grantable list covers each secret as it is now.
751
+ */
752
+ setIdentity(identity: ImpIdentity): void {
753
+ this.identity = identity;
754
+
755
+ this.staleGrantable.clear();
756
+ }
757
+
758
+ /**
759
+ * Adds a secret to impd, as `imp secret add` does, with its rules, and
760
+ * the sign-in state impd lists for a secret of kind `oauth`.
761
+ */
762
+ createSecret(
763
+ name: string,
764
+ kind: ImpSecret['kind'],
765
+ rules: readonly ImpSecretRule[],
766
+ oauth?: ImpSecret['oauth'],
767
+ ): void {
768
+ this.secrets.set(name, { name, kind, rules, ...(oauth === undefined ? {} : { oauth }) });
769
+ }
770
+
771
+ /**
772
+ * Changes a secret's rules, as a rebind does: every imp loses its grant
773
+ * of the secret, and no token's grantable list covers it any longer.
774
+ */
775
+ updateSecret(name: string, rules: readonly ImpSecretRule[]): void {
776
+ const secret = this.secrets.get(name);
777
+
778
+ if (secret === undefined) {
779
+ throw new Error(`no secret ${name}`);
780
+ }
781
+
782
+ this.secrets.set(name, { ...secret, rules });
783
+ this.removeGrants(name);
784
+ this.staleGrantable.add(name);
785
+ }
786
+
787
+ /**
788
+ * Deletes a secret, as `imp secret rm` does, with every grant of it; a
789
+ * secret made again under the name is one no token's list covers.
790
+ */
791
+ removeSecret(name: string): void {
792
+ this.secrets.delete(name);
793
+ this.removeGrants(name);
794
+ this.staleGrantable.add(name);
795
+ }
796
+
797
+ /**
798
+ * Adds a lease another principal holds on an imp, so it refuses to sleep.
799
+ */
800
+ acquireOtherLease(name: string, principal: string, label: string, ttlSeconds: number): void {
801
+ const imp = this.getImp(name);
802
+
803
+ imp.leases.set(`${principal}\u0000${label}`, {
804
+ principal,
805
+ label,
806
+ until: Date.now() + ttlSeconds * 1000,
807
+ });
808
+ }
809
+
810
+ /**
811
+ * Puts an imp to sleep with force, as a person's `imp sleep` does: every
812
+ * lease ends and every connection is detached as lost.
813
+ */
814
+ suspendWithForce(name: string): void {
815
+ const imp = this.getImp(name);
816
+
817
+ imp.leases.clear();
818
+ this.updateAsleep(imp);
819
+ }
820
+
821
+ /**
822
+ * Boots an imp cold for the given cause: every process ends without a
823
+ * delivered exit, and every connection is detached as lost.
824
+ */
825
+ bootImpCold(name: string, cause: ColdBootCause): void {
826
+ this.bootCold(this.getImp(name), cause);
827
+ }
828
+
829
+ /**
830
+ * Closes the connection on a session with a WebSocket close code, as impd
831
+ * does when it drops a send under backpressure (1011).
832
+ */
833
+ stopConnection(name: string, session: string, closeCode: number): void {
834
+ const proc = this.getImp(name).sessions.get(session);
835
+
836
+ proc?.connection?.finish({ kind: 'closed', reason: `code ${closeCode}`, closeCode });
837
+ }
838
+
839
+ /**
840
+ * Closes every guest connection on every reverse forward, as a dropped
841
+ * network does, while each forward keeps listening for the next one.
842
+ */
843
+ stopRelays(): void {
844
+ for (const forward of this.forwards) {
845
+ forward.stopRelays();
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Closes each new guest connection on every reverse forward at once, as
851
+ * a network that drops every connection does, until the refusal stops.
852
+ */
853
+ startRelayRefusal(): void {
854
+ this.refusingRelays = true;
855
+ }
856
+
857
+ stopRelayRefusal(): void {
858
+ this.refusingRelays = false;
859
+ }
860
+
861
+ /**
862
+ * Fails the broker in every imp, as a CA install that failed on boot
863
+ * does, so every start that requires it is refused, until the failure
864
+ * stops.
865
+ */
866
+ startBrokerFailure(): void {
867
+ this.brokerFailing = true;
868
+ }
869
+
870
+ stopBrokerFailure(): void {
871
+ this.brokerFailing = false;
872
+ }
873
+
874
+ /**
875
+ * Drops every byte a guest writes on a relayed connection, as a network
876
+ * that loses one direction does, until the drop stops; what the daemon
877
+ * writes still reaches the guest.
878
+ */
879
+ startGuestByteDrop(): void {
880
+ this.droppingGuestBytes = true;
881
+ }
882
+
883
+ stopGuestByteDrop(): void {
884
+ this.droppingGuestBytes = false;
885
+ }
886
+
887
+ /**
888
+ * Holds every session answer back, as a slow network does, until the
889
+ * hold stops.
890
+ */
891
+ startAnswerHold(): void {
892
+ this.held ??= [];
893
+ }
894
+
895
+ /**
896
+ * Sends every held answer, in order, and answers at once again.
897
+ */
898
+ stopAnswerHold(): void {
899
+ const held = this.held ?? [];
900
+
901
+ this.held = null;
902
+
903
+ for (const answer of held) {
904
+ answer();
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Fails a lease acquisition with an impd code once `skip` more have gone
910
+ * through.
911
+ */
912
+ setAcquireFailure(skip: number, code: string): void {
913
+ this.acquireFailure = { skip, code };
914
+ }
915
+
916
+ /**
917
+ * Drops the next session requests before impd answers them, closing
918
+ * each with the close code.
919
+ */
920
+ setSessionDrops(count: number, closeCode: number): void {
921
+ this.drops = { count, closeCode };
922
+ }
923
+
924
+ /**
925
+ * Throws from opening the next session connections, before they return.
926
+ */
927
+ setOpenFailures(count: number): void {
928
+ this.openFailures = count;
929
+ }
930
+
931
+ /**
932
+ * Fails the upgrade of the next session connections, so each ends
933
+ * unreachable without opening.
934
+ */
935
+ setUpgradeFailures(count: number): void {
936
+ this.upgradeFailures = count;
937
+ }
938
+
939
+ /**
940
+ * Holds every session connection open, as a slow WebSocket upgrade does,
941
+ * so its request reaches impd only once the hold stops.
942
+ */
943
+ startUpgradeHold(): void {
944
+ this.upgrades ??= [];
945
+ }
946
+
947
+ // How many connections wait for the upgrade hold to stop.
948
+ countHeldUpgrades(): number {
949
+ return this.upgrades?.length ?? 0;
950
+ }
951
+
952
+ /**
953
+ * Opens every held connection, in order, and the next at once.
954
+ */
955
+ stopUpgradeHold(): void {
956
+ const upgrades = this.upgrades ?? [];
957
+
958
+ this.upgrades = null;
959
+
960
+ for (const send of upgrades) {
961
+ send();
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Holds every lease acquisition, as an imp that takes long to wake does,
967
+ * until the hold stops.
968
+ */
969
+ startLeaseHold(): void {
970
+ this.leaseHold ??= Promise.withResolvers<void>();
971
+ }
972
+
973
+ /**
974
+ * Lets every held lease acquisition go through, and the next at once.
975
+ */
976
+ stopLeaseHold(): void {
977
+ this.leaseHold?.resolve();
978
+ this.leaseHold = null;
979
+ }
980
+
981
+ /**
982
+ * Holds every lease release until the hold stops.
983
+ */
984
+ startReleaseHold(): void {
985
+ this.releaseHold ??= Promise.withResolvers<void>();
986
+ }
987
+
988
+ /**
989
+ * Lets every held lease release go through, and the next at once.
990
+ */
991
+ stopReleaseHold(): void {
992
+ this.releaseHold?.resolve();
993
+ this.releaseHold = null;
994
+ }
995
+
996
+ /**
997
+ * Resolves every relative working directory inside an imp against dir,
998
+ * as impd resolves one against the guest's home, and runs every command
999
+ * with dir as its HOME.
1000
+ */
1001
+ setHomeDir(dir: string): void {
1002
+ this.homeDir = dir;
1003
+ }
1004
+
1005
+ /**
1006
+ * Holds every command whose argv holds match until the returned hold
1007
+ * stops. Throws while another hold is active, so no hold replaces one a
1008
+ * command still waits on.
1009
+ */
1010
+ startCommandHold(match: string): FixtureCommandHold {
1011
+ if (this.commandHold !== null) {
1012
+ throw new Error(`a hold on ${this.commandHold.match} is still active`);
1013
+ }
1014
+
1015
+ const hold = {
1016
+ match,
1017
+ entered: Promise.withResolvers<string>(),
1018
+ done: Promise.withResolvers<void>(),
1019
+ };
1020
+
1021
+ this.commandHold = hold;
1022
+
1023
+ return {
1024
+ entered: hold.entered.promise,
1025
+ stop: () => {
1026
+ if (this.commandHold === hold) {
1027
+ this.commandHold = null;
1028
+ }
1029
+
1030
+ hold.done.resolve();
1031
+ },
1032
+ };
1033
+ }
1034
+
1035
+ // Lets every command the active hold holds run, and the next at once.
1036
+ stopCommandHold(): void {
1037
+ const hold = this.commandHold;
1038
+
1039
+ this.commandHold = null;
1040
+ hold?.done.resolve();
1041
+ }
1042
+
1043
+ /**
1044
+ * Exits 1 from every command whose argv holds match, without running
1045
+ * it, until called with null.
1046
+ */
1047
+ setCommandFailure(match: string | null): void {
1048
+ this.commandFailure = match;
1049
+ }
1050
+
1051
+ /**
1052
+ * Fails every imp destroy with an impd code, leaving the imp, until
1053
+ * called with null.
1054
+ */
1055
+ setDestroyFailure(code: string | null): void {
1056
+ this.destroyFailure = code;
1057
+ }
1058
+
1059
+ /**
1060
+ * Fails every grant removal with an impd code until called with null.
1061
+ */
1062
+ setGrantRemovalFailure(code: string | null): void {
1063
+ this.grantRemovalFailure = code;
1064
+ }
1065
+
1066
+ /**
1067
+ * Fails the next feature reads as an unreachable impd.
1068
+ */
1069
+ setFeatureFailures(count: number): void {
1070
+ this.featureFailures = count;
1071
+ }
1072
+
1073
+ /**
1074
+ * Refuses the next session request with an impd code and its data, or the
1075
+ * message of the refusal when it carries one instead.
1076
+ */
1077
+ setNextSessionFailure(code: string | null, data: unknown, message?: string): void {
1078
+ this.nextFailure = { code, data, ...(message === undefined ? {} : { message }) };
1079
+ }
1080
+
1081
+ // The state an imp is in, or null when it does not exist.
1082
+ findState(name: string): ImpState | null {
1083
+ return this.imps.get(name)?.state ?? null;
1084
+ }
1085
+
1086
+ // The names of the imps the fixture holds.
1087
+ collectImpNames(): string[] {
1088
+ return [...this.imps.keys()];
1089
+ }
1090
+
1091
+ // The boot an imp runs in.
1092
+ getBootID(name: string): string {
1093
+ return this.getImp(name).bootId;
1094
+ }
1095
+
1096
+ // The generation running under a session name.
1097
+ getGeneration(name: string, session: string): string {
1098
+ const proc = this.getImp(name).sessions.get(session);
1099
+
1100
+ if (proc === undefined) {
1101
+ throw new Error(`no session ${session} on imp ${name}`);
1102
+ }
1103
+
1104
+ return proc.generation;
1105
+ }
1106
+
1107
+ // The offset after the last byte a session's running generation wrote.
1108
+ getEnd(name: string, session: string): number {
1109
+ const proc = this.getImp(name).sessions.get(session);
1110
+
1111
+ if (proc === undefined) {
1112
+ throw new Error(`no session ${session} on imp ${name}`);
1113
+ }
1114
+
1115
+ return proc.end;
1116
+ }
1117
+
1118
+ [Symbol.dispose](): void {
1119
+ this.stopCommandHold();
1120
+
1121
+ for (const imp of this.imps.values()) {
1122
+ for (const proc of imp.sessions.values()) {
1123
+ tryKill(proc.pty, 'SIGCONT');
1124
+ tryKill(proc.pty, 'SIGKILL');
1125
+ }
1126
+ }
1127
+
1128
+ for (const forward of this.forwards) {
1129
+ forward.stop();
1130
+ }
1131
+
1132
+ this.imps.clear();
1133
+ }
1134
+
1135
+ // impd's refusal of a grant or revoke: the scope and pattern checks run
1136
+ // before anything is looked up, then a missing imp or secret.
1137
+ private findGrantRefusal(name: string, secret: string): ImpPortError | null {
1138
+ const identity = this.identity;
1139
+ const patterns = identity.imps;
1140
+
1141
+ if (identity.scope !== 'manage') {
1142
+ return new ImpPortError('FORBIDDEN', 'the caller cannot manage grants', { reason: 'scope' });
1143
+ }
1144
+
1145
+ if (patterns !== null && !isImpNameAllowed(patterns, name)) {
1146
+ return new ImpPortError('FORBIDDEN', `imp ${name} is outside the caller's patterns`, {
1147
+ reason: 'imp_out_of_scope',
1148
+ });
1149
+ }
1150
+
1151
+ if (
1152
+ patterns !== null &&
1153
+ (!identity.grantable.includes(secret) || this.staleGrantable.has(secret))
1154
+ ) {
1155
+ return new ImpPortError('FORBIDDEN', `the caller cannot grant ${secret}`, {
1156
+ reason: 'not_grantable',
1157
+ });
1158
+ }
1159
+
1160
+ if (!this.imps.has(name)) {
1161
+ return buildNotFound(name);
1162
+ }
1163
+
1164
+ if (!this.secrets.has(secret)) {
1165
+ return new ImpPortError('NOT_FOUND', `no secret ${secret}`, { kind: 'secret', name: secret });
1166
+ }
1167
+
1168
+ return null;
1169
+ }
1170
+
1171
+ private removeGrants(secret: string): void {
1172
+ for (const imp of this.imps.values()) {
1173
+ imp.grants.delete(secret);
1174
+ }
1175
+ }
1176
+
1177
+ private getImp(name: string): FixtureImp {
1178
+ const imp = this.imps.get(name);
1179
+
1180
+ if (imp === undefined) {
1181
+ throw new Error(`no imp ${name}`);
1182
+ }
1183
+
1184
+ return imp;
1185
+ }
1186
+
1187
+ private buildView(imp: FixtureImp): ImpView {
1188
+ const live = [...imp.leases.values()].filter((lease) => lease.until > Date.now());
1189
+ const own = live.filter((lease) => lease.principal === this.principal);
1190
+ const others = live.filter((lease) => lease.principal !== this.principal);
1191
+
1192
+ return {
1193
+ id: imp.id,
1194
+ name: imp.name,
1195
+ state: imp.state,
1196
+ leases: own.map((lease) => buildLease(imp.name, lease.principal, lease.label, lease.until)),
1197
+ otherLeaseCount: new Set(others.map((lease) => lease.principal)).size,
1198
+ };
1199
+ }
1200
+
1201
+ // A sleeping imp wakes from memory with every process as it was; a
1202
+ // stopped one boots cold.
1203
+ private updateAwake(imp: FixtureImp): void {
1204
+ if (imp.state === 'sleeping') {
1205
+ imp.state = 'running';
1206
+
1207
+ for (const proc of imp.sessions.values()) {
1208
+ tryKill(proc.pty, 'SIGCONT');
1209
+ }
1210
+ } else if (imp.state !== 'running') {
1211
+ this.bootCold(imp, 'start');
1212
+ }
1213
+ }
1214
+
1215
+ private updateAsleep(imp: FixtureImp): void {
1216
+ if (imp.state !== 'running') {
1217
+ return;
1218
+ }
1219
+
1220
+ imp.state = 'sleeping';
1221
+
1222
+ for (const proc of imp.sessions.values()) {
1223
+ tryKill(proc.pty, 'SIGSTOP');
1224
+ proc.connection?.finish({ kind: 'detached', reason: 'lost', offset: proc.end });
1225
+ }
1226
+ }
1227
+
1228
+ private bootCold(imp: FixtureImp, cause: ColdBootCause): void {
1229
+ for (const proc of imp.sessions.values()) {
1230
+ proc.connection?.finish({ kind: 'detached', reason: 'lost', offset: proc.end });
1231
+ proc.ended = true;
1232
+
1233
+ tryKill(proc.pty, 'SIGCONT');
1234
+ tryKill(proc.pty, 'SIGKILL');
1235
+ }
1236
+
1237
+ imp.sessions.clear();
1238
+ imp.previous.clear();
1239
+
1240
+ imp.state = 'running';
1241
+ imp.bootId = randomUUID();
1242
+
1243
+ imp.coldBoots = [
1244
+ { bootId: imp.bootId, cause, at: new Date().toISOString() },
1245
+ ...imp.coldBoots,
1246
+ ].slice(0, 4);
1247
+ }
1248
+
1249
+ private answerSession(request: ImpSessionRequest, connection: FixtureConnection): void {
1250
+ if (connection.finished) {
1251
+ return;
1252
+ }
1253
+
1254
+ if (this.drops.count > 0) {
1255
+ this.drops.count -= 1;
1256
+
1257
+ connection.finish({
1258
+ kind: 'closed',
1259
+ reason: `code ${this.drops.closeCode}`,
1260
+ closeCode: this.drops.closeCode,
1261
+ });
1262
+
1263
+ return;
1264
+ }
1265
+
1266
+ const failure = this.nextFailure;
1267
+
1268
+ if (failure !== null) {
1269
+ this.nextFailure = null;
1270
+
1271
+ connection.finish({
1272
+ kind: 'failed',
1273
+ code: failure.code,
1274
+ message: failure.message ?? `impd refused the session (${failure.code})`,
1275
+ data: failure.data,
1276
+ });
1277
+
1278
+ return;
1279
+ }
1280
+
1281
+ const imp = this.imps.get(request.name);
1282
+
1283
+ if (imp === undefined) {
1284
+ connection.finish({
1285
+ kind: 'failed',
1286
+ code: 'NOT_FOUND',
1287
+ message: `no imp ${request.name}`,
1288
+ data: { kind: 'imp', name: request.name },
1289
+ });
1290
+
1291
+ return;
1292
+ }
1293
+
1294
+ if (imp.state !== 'running' && request.kind === 'attach' && !request.wake) {
1295
+ connection.finish({
1296
+ kind: 'failed',
1297
+ code: 'INVALID_STATE',
1298
+ message: `imp ${imp.name} is ${imp.state}`,
1299
+ data: {
1300
+ state: imp.state,
1301
+ allowed: ['running'],
1302
+ ...(imp.state === 'creating' ? {} : { coldBoots: imp.coldBoots }),
1303
+ },
1304
+ });
1305
+
1306
+ return;
1307
+ }
1308
+
1309
+ this.updateAwake(imp);
1310
+
1311
+ const running = imp.sessions.get(request.session);
1312
+ const brokerProblem = this.findBrokerProblem(imp, request, running);
1313
+
1314
+ if (brokerProblem !== null) {
1315
+ connection.finish({
1316
+ kind: 'failed',
1317
+ code: 'PRECONDITION_FAILED',
1318
+ message: `the broker is not ready in imp ${imp.name}`,
1319
+ data: { reason: 'broker_not_ready', detail: brokerProblem },
1320
+ });
1321
+
1322
+ return;
1323
+ }
1324
+
1325
+ if (running === undefined && request.kind === 'attach') {
1326
+ const previous = imp.previous.get(request.session);
1327
+
1328
+ connection.finish({
1329
+ kind: 'failed',
1330
+ code: 'NO_SESSION',
1331
+ message: `no session ${request.session}`,
1332
+ data: {
1333
+ bootId: imp.bootId,
1334
+ coldBoots: imp.coldBoots,
1335
+ ...(previous === undefined ? {} : { previous }),
1336
+ },
1337
+ });
1338
+
1339
+ return;
1340
+ }
1341
+
1342
+ const proc = running ?? this.startProcess(imp, request);
1343
+
1344
+ if (proc === null) {
1345
+ return;
1346
+ }
1347
+
1348
+ this.attachProcess(imp, proc, request, connection, running === undefined);
1349
+ }
1350
+
1351
+ // Why the broker a start or an attach requires is not ready, or null
1352
+ // when it is or the request requires nothing. A broker variable the
1353
+ // start sets, or a running process that started without the broker
1354
+ // required, fails the requirement as impd's would.
1355
+ private findBrokerProblem(
1356
+ imp: FixtureImp,
1357
+ request: ImpSessionRequest,
1358
+ running: FixtureProcess | undefined,
1359
+ ): string | null {
1360
+ if (request.require?.includes('broker') !== true) {
1361
+ return null;
1362
+ }
1363
+
1364
+ if (this.brokerFailing) {
1365
+ return 'the broker CA did not install';
1366
+ }
1367
+
1368
+ if (imp.grants.size === 0) {
1369
+ return 'the imp holds no grant';
1370
+ }
1371
+
1372
+ const overridden =
1373
+ request.kind === 'start'
1374
+ ? Object.keys(request.env).find((key) => isBrokerVariable(key))
1375
+ : undefined;
1376
+
1377
+ if (overridden !== undefined) {
1378
+ return overridden;
1379
+ }
1380
+
1381
+ return running !== undefined && !running.requireBroker
1382
+ ? 'the session did not start with the broker required'
1383
+ : null;
1384
+ }
1385
+
1386
+ // A working directory as impd resolves it inside an imp: a relative one
1387
+ // against the guest's home, when the test gave one.
1388
+ private resolveGuestCwd(cwd: string | undefined): string | undefined {
1389
+ if (this.homeDir === null || (cwd !== undefined && posix.isAbsolute(cwd))) {
1390
+ return cwd;
1391
+ }
1392
+
1393
+ return posix.resolve(this.homeDir, cwd ?? '.');
1394
+ }
1395
+
1396
+ private startProcess(imp: FixtureImp, request: ImpSessionRequest): FixtureProcess | null {
1397
+ if (request.kind !== 'start') {
1398
+ return null;
1399
+ }
1400
+
1401
+ const pty = spawn(request.argv[0] ?? 'false', request.argv.slice(1), {
1402
+ name: 'xterm-256color',
1403
+ cols: request.cols,
1404
+ rows: request.rows,
1405
+ cwd: this.resolveGuestCwd(request.cwd) ?? request.cwd,
1406
+ env: { ...request.env },
1407
+ });
1408
+
1409
+ const proc: FixtureProcess = {
1410
+ pty,
1411
+ generation: randomBytes(16).toString('hex'),
1412
+ ring: new Uint8Array(0),
1413
+ end: 0,
1414
+ exited: null,
1415
+ ended: false,
1416
+ connection: null,
1417
+ requireBroker: request.require?.includes('broker') === true,
1418
+ };
1419
+
1420
+ imp.sessions.set(request.session, proc);
1421
+
1422
+ pty.onData((text) => {
1423
+ const data = new TextEncoder().encode(text);
1424
+
1425
+ proc.ring = mergeTail(proc.ring, data, RING_BYTES);
1426
+ proc.end += data.length;
1427
+
1428
+ const connection = proc.connection;
1429
+
1430
+ if (connection !== null) {
1431
+ connection.sent = proc.end;
1432
+
1433
+ tryEmit(connection, () => {
1434
+ connection.handlers.onOutput(data);
1435
+ });
1436
+ }
1437
+ });
1438
+
1439
+ pty.onExit((exit) => {
1440
+ if (proc.ended) {
1441
+ return;
1442
+ }
1443
+
1444
+ const exitCode = exit.exitCode;
1445
+
1446
+ proc.exited = { code: exitCode };
1447
+
1448
+ imp.previous.set(request.session, {
1449
+ executionGeneration: proc.generation,
1450
+ end: proc.end,
1451
+ exitCode,
1452
+ });
1453
+
1454
+ const connection = proc.connection;
1455
+
1456
+ // An exit nobody received stays attachable until a connection
1457
+ // delivers it.
1458
+ if (connection !== null) {
1459
+ imp.sessions.delete(request.session);
1460
+ connection.finish({ kind: 'exit', code: exitCode, signal: null, offset: proc.end });
1461
+ }
1462
+ });
1463
+
1464
+ return proc;
1465
+ }
1466
+
1467
+ private attachProcess(
1468
+ imp: FixtureImp,
1469
+ proc: FixtureProcess,
1470
+ request: ImpSessionRequest,
1471
+ connection: FixtureConnection,
1472
+ created: boolean,
1473
+ ): void {
1474
+ const bufferStart = proc.end - proc.ring.length;
1475
+ let from = bufferStart;
1476
+ let prelude = '';
1477
+ let resume: ResumeResult | undefined;
1478
+
1479
+ if (request.resumeFrom !== undefined && this.continuity === 'offsets') {
1480
+ const resumeFrom = request.resumeFrom;
1481
+
1482
+ if (resumeFrom.executionGeneration !== proc.generation) {
1483
+ resume = {
1484
+ kind: 'generation_changed',
1485
+ executionGeneration: proc.generation,
1486
+ firstOffset: bufferStart,
1487
+ };
1488
+ } else if (resumeFrom.offset > proc.end) {
1489
+ connection.finish({
1490
+ kind: 'failed',
1491
+ code: 'INVALID_RESUME',
1492
+ message: 'the resume offset is past the end',
1493
+ data: { end: proc.end, bufferStart },
1494
+ });
1495
+
1496
+ return;
1497
+ } else if (resumeFrom.offset < bufferStart) {
1498
+ resume = { kind: 'gap', from: resumeFrom.offset, to: bufferStart };
1499
+ } else {
1500
+ resume = { kind: 'exact' };
1501
+ from = Math.max(bufferStart, resumeFrom.offset - this.resumeOverlap);
1502
+ }
1503
+ } else if (bufferStart > 0) {
1504
+ // A ring that has wrapped can start inside an escape sequence, so a
1505
+ // fresh attach skips to the next line and resets the modes first.
1506
+ const newline = proc.ring.indexOf(0x0a);
1507
+
1508
+ from = bufferStart + (newline === -1 ? 0 : newline + 1);
1509
+ prelude = PRELUDE;
1510
+ }
1511
+
1512
+ proc.connection?.finish({
1513
+ kind: 'detached',
1514
+ reason: 'taken_over',
1515
+ offset: proc.connection.sent,
1516
+ });
1517
+
1518
+ connection.process = proc;
1519
+ connection.sent = proc.end;
1520
+
1521
+ const preludeBytes = new TextEncoder().encode(prelude);
1522
+
1523
+ const previous = imp.previous.get(request.session);
1524
+
1525
+ const started = tryEmit(connection, () => {
1526
+ connection.handlers.onStarted({
1527
+ created,
1528
+ output:
1529
+ this.continuity === 'none'
1530
+ ? { continuity: 'none' }
1531
+ : {
1532
+ continuity: 'offsets',
1533
+ bootId: imp.bootId,
1534
+ executionGeneration: proc.generation,
1535
+ bufferStart,
1536
+ end: proc.end,
1537
+ offset: from,
1538
+ prelude: preludeBytes.length,
1539
+ coldBoots: imp.coldBoots,
1540
+ ...(previous === undefined ? {} : { previous }),
1541
+ ...(resume === undefined ? {} : { resume }),
1542
+ },
1543
+ });
1544
+ });
1545
+
1546
+ if (!started) {
1547
+ return;
1548
+ }
1549
+
1550
+ const backlog = proc.ring.subarray(from - bufferStart);
1551
+
1552
+ for (const bytes of [preludeBytes, backlog]) {
1553
+ const sent =
1554
+ bytes.length === 0 ||
1555
+ tryEmit(connection, () => {
1556
+ connection.handlers.onOutput(bytes);
1557
+ });
1558
+
1559
+ if (!sent) {
1560
+ return;
1561
+ }
1562
+ }
1563
+
1564
+ if (proc.exited !== null) {
1565
+ imp.sessions.delete(findSessionName(imp, proc));
1566
+ connection.finish({ kind: 'exit', code: proc.exited.code, signal: null, offset: proc.end });
1567
+
1568
+ return;
1569
+ }
1570
+
1571
+ proc.connection = connection;
1572
+ }
1573
+ }
1574
+
1575
+ interface FixtureLease {
1576
+ readonly principal: string;
1577
+ readonly label: string;
1578
+ readonly until: number;
1579
+ }
1580
+
1581
+ interface FixtureImp {
1582
+ readonly id: string;
1583
+ readonly name: string;
1584
+ state: ImpState;
1585
+ bootId: string;
1586
+ coldBoots: ColdBoot[];
1587
+ readonly leases: Map<string, FixtureLease>;
1588
+ readonly sessions: Map<string, FixtureProcess>;
1589
+ readonly previous: Map<string, PreviousGeneration>;
1590
+
1591
+ // The secrets granted to the imp.
1592
+ readonly grants: Set<string>;
1593
+ }
1594
+
1595
+ interface FixtureProcess {
1596
+ readonly pty: IPty;
1597
+ readonly generation: string;
1598
+ ring: Uint8Array;
1599
+ end: number;
1600
+ exited: { readonly code: number | null } | null;
1601
+
1602
+ // Ended by a cold boot: its exit is never delivered.
1603
+ ended: boolean;
1604
+ connection: FixtureConnection | null;
1605
+
1606
+ // Whether its start required the broker, which an attach that requires
1607
+ // it needs.
1608
+ readonly requireBroker: boolean;
1609
+ }
1610
+
1611
+ interface FixtureConnection {
1612
+ readonly handlers: ImpSessionHandlers;
1613
+ sent: number;
1614
+ finished: boolean;
1615
+ process: FixtureProcess | null;
1616
+ readonly finish: (outcome: ImpSessionOutcome) => void;
1617
+ }
1618
+
1619
+ interface FixtureRelay {
1620
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
1621
+ readonly dataListeners: ((data: Uint8Array) => void)[];
1622
+ readonly closeListeners: (() => void)[];
1623
+
1624
+ // Bytes a write handed over that the socket has not taken yet, and the
1625
+ // writes waiting for them to go.
1626
+ unsent: Uint8Array;
1627
+ readonly roomWaiters: (() => void)[];
1628
+ }
1629
+
1630
+ function buildNotFound(name: string): ImpPortError {
1631
+ return new ImpPortError('NOT_FOUND', `no imp ${name}`, { kind: 'imp', name });
1632
+ }
1633
+
1634
+ function buildLease(name: string, principal: string, label: string, until: number): ImpLease {
1635
+ return { name, owner: { principal, display: principal, label }, until };
1636
+ }
1637
+
1638
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- byte arrays have no readonly form
1639
+ function mergeTail(ring: Uint8Array, data: Uint8Array, limit: number): Uint8Array {
1640
+ const joined = new Uint8Array(ring.length + data.length);
1641
+
1642
+ joined.set(ring, 0);
1643
+ joined.set(data, ring.length);
1644
+
1645
+ return joined.length > limit ? joined.slice(joined.length - limit) : joined;
1646
+ }
1647
+
1648
+ // Calls a connection's handler as the imp client does: a handler that
1649
+ // throws ends the connection with a local error and closes it.
1650
+ function tryEmit(connection: FixtureConnection, emit: () => void): boolean {
1651
+ try {
1652
+ emit();
1653
+
1654
+ return true;
1655
+ } catch (error) {
1656
+ connection.finish({
1657
+ kind: 'local_error',
1658
+ detail: error instanceof Error ? error.message : String(error),
1659
+ });
1660
+
1661
+ return false;
1662
+ }
1663
+ }
1664
+
1665
+ function findSessionName(imp: FixtureImp, proc: FixtureProcess): string {
1666
+ for (const [name, held] of imp.sessions) {
1667
+ if (held === proc) {
1668
+ return name;
1669
+ }
1670
+ }
1671
+
1672
+ return '';
1673
+ }
1674
+
1675
+ function toSignal(name: string): NodeJS.Signals {
1676
+ return name === 'SIGKILL' || name === 'SIGTERM' || name === 'SIGINT' ? name : 'SIGHUP';
1677
+ }
1678
+
1679
+ function tryKill(pty: IPty, signal: NodeJS.Signals): void {
1680
+ try {
1681
+ process.kill(pty.pid, signal);
1682
+ } catch {}
1683
+ }
1684
+
1685
+ // A gate that throws keeps the request from going out, as a closed one does.
1686
+ function tryPassGate(gate: () => boolean): boolean {
1687
+ try {
1688
+ return gate();
1689
+ } catch {
1690
+ return false;
1691
+ }
1692
+ }