@zgeoff/atc 3.3.1 → 3.4.1

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 (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -29,6 +29,7 @@ import type {
29
29
  import { ImpPortError } from '../daemon/imp-port-error';
30
30
  import { isImpNameAllowed } from '../daemon/is-imp-name-allowed';
31
31
  import { isBrokerVariable } from '../shared/is-broker-variable';
32
+ import { registerTestCleanup } from './register-test-cleanup';
32
33
 
33
34
  // impd keeps exactly this many bytes of each generation's output.
34
35
  const RING_BYTES = 262_144;
@@ -36,45 +37,33 @@ const RING_BYTES = 262_144;
36
37
  // The mode bytes a fresh attach sends ahead of a ring that has wrapped.
37
38
  const PRELUDE = '\u001B[0m';
38
39
 
39
- // The PATH every fixture process runs with: a guest's own, never the
40
+ // The PATH every stub process runs with: a guest's own, never the
40
41
  // daemon's.
41
42
  const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
42
43
 
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
44
  // A hold on the commands whose argv holds its text: entered resolves with
64
45
  // the argv of the first command it holds, and stop lets every command it
65
- // holds run.
66
- interface FixtureCommandHold {
46
+ // holds run. Disposing the hold stops it, so a test that holds it with
47
+ // `using` after its daemon lets the daemon stop even when the test fails.
48
+ interface StubCommandHold {
67
49
  readonly entered: Promise<string>;
68
50
  readonly stop: () => void;
51
+ readonly [Symbol.dispose]: () => void;
69
52
  }
70
53
 
71
- export class FixtureImpPort implements ImpPort {
54
+ /**
55
+ * The impd stand-in the factory below creates, one per call.
56
+ */
57
+ class StubImpPort implements ImpPort {
72
58
  // Every port call, in order, as `<call> <imp> [<detail>]`.
73
59
  readonly calls: string[] = [];
74
60
 
75
61
  // Every session request the port received, in order.
76
62
  readonly sessionRequests: ImpSessionRequest[] = [];
77
63
 
64
+ // The spec of every imp create the port received, in order.
65
+ readonly createSpecs: ImpCreateSpec[] = [];
66
+
78
67
  features: ImpFeatures = {
79
68
  sessionOffsets: true,
80
69
  leases: true,
@@ -150,6 +139,11 @@ export class FixtureImpPort implements ImpPort {
150
139
  // The lease acquisitions wait for this hold to end, while one is held.
151
140
  private leaseHold: PromiseWithResolvers<void> | null = null;
152
141
 
142
+ // How many lease acquisitions and releases wait on their holds.
143
+ private heldLeases = 0;
144
+
145
+ private heldReleases = 0;
146
+
153
147
  // The directory a relative or missing working directory resolves
154
148
  // against inside every imp, as a guest's home does; null for the test's
155
149
  // own working directory.
@@ -163,6 +157,9 @@ export class FixtureImpPort implements ImpPort {
163
157
  readonly done: PromiseWithResolvers<void>;
164
158
  } | null = null;
165
159
 
160
+ // How many commands wait on a command hold.
161
+ private heldCommands = 0;
162
+
166
163
  // Commands whose argv holds this text exit 1 without running, or null.
167
164
  private commandFailure: string | null = null;
168
165
 
@@ -183,9 +180,12 @@ export class FixtureImpPort implements ImpPort {
183
180
  // what the daemon writes still reaches the guest.
184
181
  private droppingGuestBytes = false;
185
182
 
183
+ // How many guest bytes the drop has thrown away.
184
+ private droppedGuestBytes = 0;
185
+
186
186
  private readonly principal: string;
187
187
 
188
- private readonly imps = new Map<string, FixtureImp>();
188
+ private readonly imps = new Map<string, StubImp>();
189
189
 
190
190
  private readonly forwards = new Set<{ stop: () => void; stopRelays: () => void }>();
191
191
 
@@ -301,6 +301,7 @@ export class FixtureImpPort implements ImpPort {
301
301
 
302
302
  createImp(spec: ImpCreateSpec): Promise<ImpView> {
303
303
  this.calls.push(`imps.create ${spec.name}`);
304
+ this.createSpecs.push(spec);
304
305
 
305
306
  if (this.imps.has(spec.name)) {
306
307
  return Promise.reject(
@@ -308,7 +309,7 @@ export class FixtureImpPort implements ImpPort {
308
309
  );
309
310
  }
310
311
 
311
- const imp: FixtureImp = {
312
+ const imp: StubImp = {
312
313
  id: randomUUID(),
313
314
  name: spec.name,
314
315
  state: 'stopped',
@@ -335,8 +336,12 @@ export class FixtureImpPort implements ImpPort {
335
336
  }
336
337
 
337
338
  private async waitForLease(name: string, label: string, ttlSeconds: number): Promise<ImpLease> {
339
+ this.heldLeases += 1;
340
+
338
341
  await this.leaseHold?.promise;
339
342
 
343
+ this.heldLeases -= 1;
344
+
340
345
  return this.applyLease(name, label, ttlSeconds);
341
346
  }
342
347
 
@@ -401,8 +406,12 @@ export class FixtureImpPort implements ImpPort {
401
406
  }
402
407
 
403
408
  private async waitForRelease(name: string, label: string): Promise<boolean> {
409
+ this.heldReleases += 1;
410
+
404
411
  await this.releaseHold?.promise;
405
412
 
413
+ this.heldReleases -= 1;
414
+
406
415
  return this.applyRelease(name, label);
407
416
  }
408
417
 
@@ -486,7 +495,7 @@ export class FixtureImpPort implements ImpPort {
486
495
 
487
496
  const outcome = Promise.withResolvers<ImpSessionOutcome>();
488
497
 
489
- const connection: FixtureConnection = {
498
+ const connection: StubConnection = {
490
499
  handlers,
491
500
  sent: 0,
492
501
  finished: false,
@@ -590,7 +599,11 @@ export class FixtureImpPort implements ImpPort {
590
599
  if (hold !== null && line.includes(hold.match)) {
591
600
  hold.entered.resolve(line);
592
601
 
602
+ this.heldCommands += 1;
603
+
593
604
  await hold.done.promise;
605
+
606
+ this.heldCommands -= 1;
594
607
  }
595
608
 
596
609
  if (this.commandFailure !== null && line.includes(this.commandFailure)) {
@@ -625,11 +638,11 @@ export class FixtureImpPort implements ImpPort {
625
638
  ): ImpReverseForward {
626
639
  this.calls.push(`reverse ${name} ${guestPath}`);
627
640
 
628
- const server = Bun.listen<FixtureRelay>({
641
+ const server = Bun.listen<StubRelay>({
629
642
  unix: guestPath,
630
643
  socket: {
631
644
  open: (socket) => {
632
- const relay: FixtureRelay = {
645
+ const relay: StubRelay = {
633
646
  dataListeners: [],
634
647
  closeListeners: [],
635
648
  unsent: new Uint8Array(0),
@@ -675,6 +688,8 @@ export class FixtureImpPort implements ImpPort {
675
688
  },
676
689
  data: (socket, buf) => {
677
690
  if (this.droppingGuestBytes) {
691
+ this.droppedGuestBytes += buf.length;
692
+
678
693
  return;
679
694
  }
680
695
 
@@ -884,6 +899,11 @@ export class FixtureImpPort implements ImpPort {
884
899
  this.droppingGuestBytes = false;
885
900
  }
886
901
 
902
+ // How many bytes guests wrote that the drop threw away.
903
+ countDroppedGuestBytes(): number {
904
+ return this.droppedGuestBytes;
905
+ }
906
+
887
907
  /**
888
908
  * Holds every session answer back, as a slow network does, until the
889
909
  * hold stops.
@@ -892,6 +912,11 @@ export class FixtureImpPort implements ImpPort {
892
912
  this.held ??= [];
893
913
  }
894
914
 
915
+ // How many session answers wait for the answer hold to stop.
916
+ countHeldAnswers(): number {
917
+ return this.held?.length ?? 0;
918
+ }
919
+
895
920
  /**
896
921
  * Sends every held answer, in order, and answers at once again.
897
922
  */
@@ -970,6 +995,11 @@ export class FixtureImpPort implements ImpPort {
970
995
  this.leaseHold ??= Promise.withResolvers<void>();
971
996
  }
972
997
 
998
+ // How many lease acquisitions wait for the lease hold to stop.
999
+ countHeldLeases(): number {
1000
+ return this.heldLeases;
1001
+ }
1002
+
973
1003
  /**
974
1004
  * Lets every held lease acquisition go through, and the next at once.
975
1005
  */
@@ -985,6 +1015,11 @@ export class FixtureImpPort implements ImpPort {
985
1015
  this.releaseHold ??= Promise.withResolvers<void>();
986
1016
  }
987
1017
 
1018
+ // How many lease releases wait for the release hold to stop.
1019
+ countHeldReleases(): number {
1020
+ return this.heldReleases;
1021
+ }
1022
+
988
1023
  /**
989
1024
  * Lets every held lease release go through, and the next at once.
990
1025
  */
@@ -1007,7 +1042,7 @@ export class FixtureImpPort implements ImpPort {
1007
1042
  * stops. Throws while another hold is active, so no hold replaces one a
1008
1043
  * command still waits on.
1009
1044
  */
1010
- startCommandHold(match: string): FixtureCommandHold {
1045
+ startCommandHold(match: string): StubCommandHold {
1011
1046
  if (this.commandHold !== null) {
1012
1047
  throw new Error(`a hold on ${this.commandHold.match} is still active`);
1013
1048
  }
@@ -1020,16 +1055,20 @@ export class FixtureImpPort implements ImpPort {
1020
1055
 
1021
1056
  this.commandHold = hold;
1022
1057
 
1023
- return {
1024
- entered: hold.entered.promise,
1025
- stop: () => {
1026
- if (this.commandHold === hold) {
1027
- this.commandHold = null;
1028
- }
1058
+ const stop = () => {
1059
+ if (this.commandHold === hold) {
1060
+ this.commandHold = null;
1061
+ }
1029
1062
 
1030
- hold.done.resolve();
1031
- },
1063
+ hold.done.resolve();
1032
1064
  };
1065
+
1066
+ return { entered: hold.entered.promise, stop, [Symbol.dispose]: stop };
1067
+ }
1068
+
1069
+ // How many commands wait for their command hold to stop.
1070
+ countHeldCommands(): number {
1071
+ return this.heldCommands;
1033
1072
  }
1034
1073
 
1035
1074
  // Lets every command the active hold holds run, and the next at once.
@@ -1083,7 +1122,7 @@ export class FixtureImpPort implements ImpPort {
1083
1122
  return this.imps.get(name)?.state ?? null;
1084
1123
  }
1085
1124
 
1086
- // The names of the imps the fixture holds.
1125
+ // The names of the imps the stub holds.
1087
1126
  collectImpNames(): string[] {
1088
1127
  return [...this.imps.keys()];
1089
1128
  }
@@ -1104,6 +1143,15 @@ export class FixtureImpPort implements ImpPort {
1104
1143
  return proc.generation;
1105
1144
  }
1106
1145
 
1146
+ /**
1147
+ * The exit a session's process left that no connection has received, or
1148
+ * null while the process runs, once an attach delivered the exit, and for
1149
+ * a session the imp does not hold.
1150
+ */
1151
+ findUndeliveredExit(name: string, session: string): { readonly code: number | null } | null {
1152
+ return this.imps.get(name)?.sessions.get(session)?.exited ?? null;
1153
+ }
1154
+
1107
1155
  // The offset after the last byte a session's running generation wrote.
1108
1156
  getEnd(name: string, session: string): number {
1109
1157
  const proc = this.getImp(name).sessions.get(session);
@@ -1174,7 +1222,7 @@ export class FixtureImpPort implements ImpPort {
1174
1222
  }
1175
1223
  }
1176
1224
 
1177
- private getImp(name: string): FixtureImp {
1225
+ private getImp(name: string): StubImp {
1178
1226
  const imp = this.imps.get(name);
1179
1227
 
1180
1228
  if (imp === undefined) {
@@ -1184,7 +1232,7 @@ export class FixtureImpPort implements ImpPort {
1184
1232
  return imp;
1185
1233
  }
1186
1234
 
1187
- private buildView(imp: FixtureImp): ImpView {
1235
+ private buildView(imp: StubImp): ImpView {
1188
1236
  const live = [...imp.leases.values()].filter((lease) => lease.until > Date.now());
1189
1237
  const own = live.filter((lease) => lease.principal === this.principal);
1190
1238
  const others = live.filter((lease) => lease.principal !== this.principal);
@@ -1200,7 +1248,7 @@ export class FixtureImpPort implements ImpPort {
1200
1248
 
1201
1249
  // A sleeping imp wakes from memory with every process as it was; a
1202
1250
  // stopped one boots cold.
1203
- private updateAwake(imp: FixtureImp): void {
1251
+ private updateAwake(imp: StubImp): void {
1204
1252
  if (imp.state === 'sleeping') {
1205
1253
  imp.state = 'running';
1206
1254
 
@@ -1212,7 +1260,7 @@ export class FixtureImpPort implements ImpPort {
1212
1260
  }
1213
1261
  }
1214
1262
 
1215
- private updateAsleep(imp: FixtureImp): void {
1263
+ private updateAsleep(imp: StubImp): void {
1216
1264
  if (imp.state !== 'running') {
1217
1265
  return;
1218
1266
  }
@@ -1225,7 +1273,7 @@ export class FixtureImpPort implements ImpPort {
1225
1273
  }
1226
1274
  }
1227
1275
 
1228
- private bootCold(imp: FixtureImp, cause: ColdBootCause): void {
1276
+ private bootCold(imp: StubImp, cause: ColdBootCause): void {
1229
1277
  for (const proc of imp.sessions.values()) {
1230
1278
  proc.connection?.finish({ kind: 'detached', reason: 'lost', offset: proc.end });
1231
1279
  proc.ended = true;
@@ -1246,7 +1294,7 @@ export class FixtureImpPort implements ImpPort {
1246
1294
  ].slice(0, 4);
1247
1295
  }
1248
1296
 
1249
- private answerSession(request: ImpSessionRequest, connection: FixtureConnection): void {
1297
+ private answerSession(request: ImpSessionRequest, connection: StubConnection): void {
1250
1298
  if (connection.finished) {
1251
1299
  return;
1252
1300
  }
@@ -1353,9 +1401,9 @@ export class FixtureImpPort implements ImpPort {
1353
1401
  // start sets, or a running process that started without the broker
1354
1402
  // required, fails the requirement as impd's would.
1355
1403
  private findBrokerProblem(
1356
- imp: FixtureImp,
1404
+ imp: StubImp,
1357
1405
  request: ImpSessionRequest,
1358
- running: FixtureProcess | undefined,
1406
+ running: StubProcess | undefined,
1359
1407
  ): string | null {
1360
1408
  if (request.require?.includes('broker') !== true) {
1361
1409
  return null;
@@ -1393,7 +1441,7 @@ export class FixtureImpPort implements ImpPort {
1393
1441
  return posix.resolve(this.homeDir, cwd ?? '.');
1394
1442
  }
1395
1443
 
1396
- private startProcess(imp: FixtureImp, request: ImpSessionRequest): FixtureProcess | null {
1444
+ private startProcess(imp: StubImp, request: ImpSessionRequest): StubProcess | null {
1397
1445
  if (request.kind !== 'start') {
1398
1446
  return null;
1399
1447
  }
@@ -1406,7 +1454,7 @@ export class FixtureImpPort implements ImpPort {
1406
1454
  env: { ...request.env },
1407
1455
  });
1408
1456
 
1409
- const proc: FixtureProcess = {
1457
+ const proc: StubProcess = {
1410
1458
  pty,
1411
1459
  generation: randomBytes(16).toString('hex'),
1412
1460
  ring: new Uint8Array(0),
@@ -1465,10 +1513,10 @@ export class FixtureImpPort implements ImpPort {
1465
1513
  }
1466
1514
 
1467
1515
  private attachProcess(
1468
- imp: FixtureImp,
1469
- proc: FixtureProcess,
1516
+ imp: StubImp,
1517
+ proc: StubProcess,
1470
1518
  request: ImpSessionRequest,
1471
- connection: FixtureConnection,
1519
+ connection: StubConnection,
1472
1520
  created: boolean,
1473
1521
  ): void {
1474
1522
  const bufferStart = proc.end - proc.ring.length;
@@ -1572,27 +1620,60 @@ export class FixtureImpPort implements ImpPort {
1572
1620
  }
1573
1621
  }
1574
1622
 
1575
- interface FixtureLease {
1623
+ /**
1624
+ * Creates an in-process stand-in for impd behind the imp port, calling impd
1625
+ * as the principal, `token:atc` when absent. Each imp is a set of
1626
+ * real `bun-pty` processes on this machine; a sleeping imp stops them with
1627
+ * SIGSTOP and a wake continues them, so a memory wake keeps each process
1628
+ * and its generation. Every session keeps an exact 262144-byte ring with
1629
+ * offsets, a fresh attach skips to the next line and sends a mode prelude,
1630
+ * and impd's refusals carry its codes and data: `LEASED`, `LEASE_NOT_HELD`,
1631
+ * `NO_SESSION`, `INVALID_STATE`, `INVALID_RESUME`, `NOT_FOUND`, `CONFLICT`,
1632
+ * and `FORBIDDEN`. Leases belong to principals; the port acts as
1633
+ * `principal`, and a test adds other owners' leases, cold boots, and
1634
+ * dropped sockets through the controls. Grants follow impd 0.27: the
1635
+ * caller's identity must reach the imp and, under imp patterns, list the
1636
+ * secret as grantable; a grant is idempotent, one secret per host, and a
1637
+ * destroyed imp or a rebound or removed secret takes its grants with it. A
1638
+ * start or an attach that requires the broker is refused with
1639
+ * `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
1640
+ * while the broker fails or the imp holds no grant, when the start sets a
1641
+ * broker variable, and when it would join a process that started without
1642
+ * the broker required. Once the current test finishes, the stand-in kills
1643
+ * every process and stops every forward it holds, so it must be created inside
1644
+ * a test; disposal does so sooner, and a second disposal does nothing.
1645
+ */
1646
+ export function createStubImpPort(principal = 'token:atc'): StubImpPort {
1647
+ const port = new StubImpPort(principal);
1648
+
1649
+ registerTestCleanup(() => {
1650
+ port[Symbol.dispose]();
1651
+ });
1652
+
1653
+ return port;
1654
+ }
1655
+
1656
+ interface StubLease {
1576
1657
  readonly principal: string;
1577
1658
  readonly label: string;
1578
1659
  readonly until: number;
1579
1660
  }
1580
1661
 
1581
- interface FixtureImp {
1662
+ interface StubImp {
1582
1663
  readonly id: string;
1583
1664
  readonly name: string;
1584
1665
  state: ImpState;
1585
1666
  bootId: string;
1586
1667
  coldBoots: ColdBoot[];
1587
- readonly leases: Map<string, FixtureLease>;
1588
- readonly sessions: Map<string, FixtureProcess>;
1668
+ readonly leases: Map<string, StubLease>;
1669
+ readonly sessions: Map<string, StubProcess>;
1589
1670
  readonly previous: Map<string, PreviousGeneration>;
1590
1671
 
1591
1672
  // The secrets granted to the imp.
1592
1673
  readonly grants: Set<string>;
1593
1674
  }
1594
1675
 
1595
- interface FixtureProcess {
1676
+ interface StubProcess {
1596
1677
  readonly pty: IPty;
1597
1678
  readonly generation: string;
1598
1679
  ring: Uint8Array;
@@ -1601,22 +1682,22 @@ interface FixtureProcess {
1601
1682
 
1602
1683
  // Ended by a cold boot: its exit is never delivered.
1603
1684
  ended: boolean;
1604
- connection: FixtureConnection | null;
1685
+ connection: StubConnection | null;
1605
1686
 
1606
1687
  // Whether its start required the broker, which an attach that requires
1607
1688
  // it needs.
1608
1689
  readonly requireBroker: boolean;
1609
1690
  }
1610
1691
 
1611
- interface FixtureConnection {
1692
+ interface StubConnection {
1612
1693
  readonly handlers: ImpSessionHandlers;
1613
1694
  sent: number;
1614
1695
  finished: boolean;
1615
- process: FixtureProcess | null;
1696
+ process: StubProcess | null;
1616
1697
  readonly finish: (outcome: ImpSessionOutcome) => void;
1617
1698
  }
1618
1699
 
1619
- interface FixtureRelay {
1700
+ interface StubRelay {
1620
1701
  // oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
1621
1702
  readonly dataListeners: ((data: Uint8Array) => void)[];
1622
1703
  readonly closeListeners: (() => void)[];
@@ -1647,7 +1728,7 @@ function mergeTail(ring: Uint8Array, data: Uint8Array, limit: number): Uint8Arra
1647
1728
 
1648
1729
  // Calls a connection's handler as the imp client does: a handler that
1649
1730
  // throws ends the connection with a local error and closes it.
1650
- function tryEmit(connection: FixtureConnection, emit: () => void): boolean {
1731
+ function tryEmit(connection: StubConnection, emit: () => void): boolean {
1651
1732
  try {
1652
1733
  emit();
1653
1734
 
@@ -1662,7 +1743,7 @@ function tryEmit(connection: FixtureConnection, emit: () => void): boolean {
1662
1743
  }
1663
1744
  }
1664
1745
 
1665
- function findSessionName(imp: FixtureImp, proc: FixtureProcess): string {
1746
+ function findSessionName(imp: StubImp, proc: StubProcess): string {
1666
1747
  for (const [name, held] of imp.sessions) {
1667
1748
  if (held === proc) {
1668
1749
  return name;
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ /**
5
+ * Creates a stand-in for the Claude CLI as `fake-claude` under the
6
+ * directory and returns its path. Each start appends its arguments to
7
+ * `claude-starts.log` under the same directory, one per line, ends the
8
+ * start with an empty line, and then stays up without reporting anything,
9
+ * as a session whose agent never speaks does. The log exists only once a
10
+ * start has run, so a test proves that no harness started by its absence.
11
+ */
12
+ export function createStubRecordingClaude(dir: string): string {
13
+ const log = join(dir, 'claude-starts.log');
14
+
15
+ return createStubBin(
16
+ dir,
17
+ 'fake-claude',
18
+ `#!/bin/sh\n{ printf '%s\\n' "$@"; echo; } >> '${log}'\nexec sleep 30\n`,
19
+ );
20
+ }
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ /**
5
+ * Creates a stand-in for the Codex CLI as `fake-codex` under the directory
6
+ * and returns its path. Each start appends one line to `codex-starts.log`
7
+ * under the same directory: the `CODEX_HOME` it started with, then its
8
+ * arguments, separated by spaces. It then stays up without reporting
9
+ * anything. The log exists only once a start has run, so a test proves that
10
+ * no harness started by its absence.
11
+ */
12
+ export function createStubRecordingCodex(dir: string): string {
13
+ const log = join(dir, 'codex-starts.log');
14
+
15
+ return createStubBin(
16
+ dir,
17
+ 'fake-codex',
18
+ `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\nexec sleep 30\n`,
19
+ );
20
+ }
@@ -0,0 +1,56 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ interface StubSharedServerCodexConfig {
5
+ // The command atc runs as; the stub reports each hook through its
6
+ // `hook-report --agent codex`, as the hook entries atc prints for Codex do.
7
+ readonly atc: readonly string[];
8
+
9
+ // The composer script the stub finishes in.
10
+ readonly composer: string;
11
+ }
12
+
13
+ /**
14
+ * Creates a stand-in for a Codex CLI that shares one background server
15
+ * between its terminals, as `fake-codex` under the directory, and returns
16
+ * its path. Without `--no-daemon`, the first start records its own
17
+ * `ATC_SESSION_ID` and `ATC_SOCKET` in `codex-shared-server.env` under the
18
+ * directory, as the server it starts keeps its environment, and every start
19
+ * runs its hooks with the recorded pair, as Codex runs hooks inside that
20
+ * server. With `--no-daemon`, a start runs its hooks with its own pair. The
21
+ * stub prints its arguments, reports a `SessionStart` for its thread, which
22
+ * is `fake-thread-<its own ATC_SESSION_ID>` or the id after `resume`, then
23
+ * a `Stop` whose last message is `done <thread>`, then runs the composer.
24
+ */
25
+ export function createStubSharedServerCodex(
26
+ dir: string,
27
+ config: StubSharedServerCodexConfig,
28
+ ): string {
29
+ const report = `${config.atc.map((part) => `"${part}"`).join(' ')} hook-report --agent codex`;
30
+ const server = join(dir, 'codex-shared-server.env');
31
+
32
+ return createStubBin(
33
+ dir,
34
+ 'fake-codex',
35
+ `#!/usr/bin/env bash
36
+ echo "FAKE_CODEX_UP args: $*"
37
+ thread="fake-thread-$ATC_SESSION_ID"
38
+ source=startup
39
+ prev=''
40
+ for arg in "$@"; do
41
+ if [ "$prev" = resume ]; then thread="$arg"; source=resume; fi
42
+ prev="$arg"
43
+ done
44
+ case " $* " in
45
+ *' --no-daemon '*) ;;
46
+ *)
47
+ (set -o noclobber; printf 'ATC_SESSION_ID=%q\\nATC_SOCKET=%q\\n' "$ATC_SESSION_ID" "$ATC_SOCKET" > '${server}') 2>/dev/null
48
+ . '${server}'
49
+ ;;
50
+ esac
51
+ printf '{"hook_event_name":"SessionStart","session_id":"%s","source":"%s"}' "$thread" "$source" | ${report}
52
+ printf '{"hook_event_name":"Stop","session_id":"%s","last_assistant_message":"done %s"}' "$thread" "$thread" | ${report}
53
+ exec "${process.execPath}" "${config.composer}"
54
+ `,
55
+ );
56
+ }
@@ -1,9 +1,10 @@
1
1
  import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { registerTestCleanup } from './register-test-cleanup';
4
5
 
5
- interface FakeSystemd {
6
- // The directory that holds the fake `systemctl` and `systemd-run`, to put
6
+ interface StubSystemd {
7
+ // The directory that holds the stand-in `systemctl` and `systemd-run`, to put
7
8
  // first on the PATH of every process that could reach a real one.
8
9
  readonly binDir: string;
9
10
 
@@ -27,17 +28,24 @@ interface FakeSystemd {
27
28
  }
28
29
 
29
30
  /**
30
- * Writes a fake `systemctl` and `systemd-run` that touch no service
31
+ * Writes a stand-in `systemctl` and `systemd-run` that touch no service
31
32
  * manager. Without a MainPID, `systemctl show -p MainPID` prints a pid no
32
33
  * process holds, so no daemon is ever a unit's main process. With
33
34
  * `writeMainPID`, `systemctl restart` stops that pid with SIGTERM and starts
34
35
  * `atcCommand daemon` in the background. `systemd-run` runs the command after
35
36
  * `--` in the background with only the `--setenv` variables, as a transient
36
37
  * unit sees them, and appends its output to the `StandardOutput=append:`
37
- * file. Every call is logged.
38
+ * file. Every call is logged. The stand-ins' directory is removed once the
39
+ * current test finishes, so it must run inside a test; disposal removes it
40
+ * sooner, and a second removal does nothing.
38
41
  */
39
- export function setupFakeSystemd(atcCommand: readonly string[]): FakeSystemd {
40
- const root = mkdtempSync(join(tmpdir(), 'atc-fake-systemd-'));
42
+ export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
43
+ const root = mkdtempSync(join(tmpdir(), 'atc-stub-systemd-'));
44
+
45
+ const remove = registerTestCleanup(() => {
46
+ rmSync(root, { recursive: true, force: true });
47
+ });
48
+
41
49
  const binDir = join(root, 'bin');
42
50
  const procRoot = join(root, 'proc');
43
51
  const atcLine = atcCommand.map((part) => `"${part}"`).join(' ');
@@ -118,8 +126,6 @@ exit 0
118
126
  `0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
119
127
  );
120
128
  },
121
- [Symbol.dispose]: () => {
122
- rmSync(root, { recursive: true, force: true });
123
- },
129
+ [Symbol.dispose]: remove,
124
130
  };
125
131
  }
@@ -4,11 +4,11 @@ import { getAgentEntry } from './get-agent-entry';
4
4
 
5
5
  /**
6
6
  * The gateway a parsed config holds under an agent id: a Claude entry with a
7
- * base URL. Throws when the config holds no such agent or the entry has no
8
- * base URL.
7
+ * base URL, without the registry's `kind`, which a gateway does not carry.
8
+ * Throws when the config holds no such agent or the entry has no base URL.
9
9
  */
10
10
  export function getGatewayConfig(config: Pick<Config, 'agents'>, id: string): GatewayConfig {
11
- const entry = getAgentEntry(config, id);
11
+ const { kind: _kind, ...entry } = getAgentEntry(config, id);
12
12
 
13
13
  if (entry.baseURL === undefined) {
14
14
  throw new Error(`the agent '${id}' has no baseURL`);
@@ -0,0 +1,21 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ /**
4
+ * Returns the effect ref of the one idempotency claim a state database
5
+ * holds, and throws when it holds none or several, so a test that expects
6
+ * one claim never reads another's.
7
+ */
8
+ export function getOnlyEffectRef(db: Database): string {
9
+ const refs = db
10
+ .query<{ effect_ref: string }, []>('SELECT effect_ref FROM idempotency')
11
+ .all()
12
+ .map((row) => row.effect_ref);
13
+
14
+ const [ref] = refs;
15
+
16
+ if (refs.length !== 1 || ref === undefined) {
17
+ throw new Error(`expected one idempotency claim, found ${JSON.stringify(refs)}`);
18
+ }
19
+
20
+ return ref;
21
+ }