@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
@@ -0,0 +1,19 @@
1
+ import type { createStubImpPort } from './create-stub-imp-port';
2
+
3
+ /**
4
+ * Returns the name of the one imp a stub imp port holds, and throws when
5
+ * it holds none or several, so a test that expects one spawn's imp never
6
+ * reads another's.
7
+ */
8
+ export function getOnlyImpName(
9
+ port: Readonly<Pick<ReturnType<typeof createStubImpPort>, 'collectImpNames'>>,
10
+ ): string {
11
+ const names = port.collectImpNames();
12
+ const [name] = names;
13
+
14
+ if (names.length !== 1 || name === undefined) {
15
+ throw new Error(`expected one imp, found ${JSON.stringify(names)}`);
16
+ }
17
+
18
+ return name;
19
+ }
@@ -0,0 +1,20 @@
1
+ import { isRecord } from '../shared/report';
2
+
3
+ /**
4
+ * Narrows one field of a record to an array of records, for tests reading a
5
+ * list out of a loosely typed protocol reply, such as the sessions of
6
+ * `session.list`. Throws when the field is not an array or holds anything
7
+ * but records, so a malformed reply fails the test instead of passing it.
8
+ */
9
+ export function getRecords(
10
+ value: Readonly<Record<string, unknown>>,
11
+ key: string,
12
+ ): Record<string, unknown>[] {
13
+ const inner = value[key];
14
+
15
+ if (!Array.isArray(inner) || !inner.every((item) => isRecord(item))) {
16
+ throw new TypeError(`${key} is not an array of records`);
17
+ }
18
+
19
+ return inner;
20
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Narrows one field of a record to a string, for tests pulling an id or a
3
+ * command out of a loosely typed protocol reply. Throws rather than letting a
4
+ * malformed reply pass a test vacuously.
5
+ */
6
+ export function getString(value: Readonly<Record<string, unknown>>, key: string): string {
7
+ const inner = value[key];
8
+
9
+ if (typeof inner !== 'string') {
10
+ throw new TypeError(`${key} is not a string`);
11
+ }
12
+
13
+ return inner;
14
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The control bytes and escape sequences tests type into a terminal, by
3
+ * name, so a test reads `KEYS.esc` where it would otherwise hold an opaque
4
+ * escape. `bel` is the one entry a terminal writes rather than reads: the
5
+ * byte that ends an OSC sequence on screen.
6
+ */
7
+ export const KEYS = Object.freeze({
8
+ ctrlSpace: '\u0000',
9
+ ctrlA: '\u0001',
10
+ ctrlB: '\u0002',
11
+ ctrlC: '\u0003',
12
+ bel: '\u0007',
13
+ tab: '\u0009',
14
+ ctrlJ: '\u000A',
15
+ enter: '\u000D',
16
+ ctrlU: '\u0015',
17
+ esc: '\u001B',
18
+ ctrlRightBracket: '\u001D',
19
+ backspace: '\u007F',
20
+ up: '\u001B[A',
21
+ down: '\u001B[B',
22
+ right: '\u001B[C',
23
+ left: '\u001B[D',
24
+ pasteOpen: '\u001B[200~',
25
+ pasteClose: '\u001B[201~',
26
+ });
@@ -0,0 +1,36 @@
1
+ import type { MockOverrides } from './mock-overrides';
2
+
3
+ /**
4
+ * A copy of the defaults with each override laid over it: a plain object
5
+ * on both sides merges key by key at every depth, and any other value,
6
+ * arrays and class instances included, replaces the default whole. Neither
7
+ * argument changes. A mock factory passes its fresh defaults here, so a
8
+ * nested override keeps every default beside it. `K` holds the keys the
9
+ * defaults set; an override of any other key must be whole.
10
+ */
11
+ export function mergeDeep<T extends object, K extends keyof T = keyof T>(
12
+ defaults: T,
13
+ overrides: MockOverrides<T, K>,
14
+ ): T {
15
+ // oxlint-disable-next-line no-unsafe-type-assertion -- merging a deep partial of T over a whole T yields a whole T
16
+ return mergeRecords(defaults, overrides) as T;
17
+ }
18
+
19
+ function mergeRecords(base: object, patch: object): Record<string, unknown> {
20
+ const merged: Record<string, unknown> = { ...base };
21
+
22
+ for (const [key, value] of Object.entries(patch)) {
23
+ const current = merged[key];
24
+
25
+ merged[key] =
26
+ isPlainObject(current) && isPlainObject(value) ? mergeRecords(current, value) : value;
27
+ }
28
+
29
+ return merged;
30
+ }
31
+
32
+ function isPlainObject(value: unknown): value is object {
33
+ return (
34
+ typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype
35
+ );
36
+ }
@@ -0,0 +1,13 @@
1
+ import type { PartialDeep } from 'type-fest';
2
+
3
+ /**
4
+ * The overrides a mock factory takes for a type whose defaults set the
5
+ * keys `K`: a field the defaults set takes a deep partial, merged into the
6
+ * default, and any other field takes a whole value, since there is no
7
+ * default to merge a partial one into.
8
+ */
9
+ export type MockOverrides<T, K extends keyof T> = {
10
+ readonly [P in K]?: PartialDeep<T[P]>;
11
+ } & {
12
+ readonly [P in Exclude<keyof T, K>]?: T[P];
13
+ };
@@ -0,0 +1,24 @@
1
+ import { KEYS } from './keys';
2
+ import type { TUIHarness } from './start-tui-harness';
3
+
4
+ /**
5
+ * Opens the GitHub repository step of a Claude spawn from the home screen:
6
+ * the first agent, then a tab from the directory step to the next source.
7
+ * Resolves once the step's title is drawn, with the capture holding what
8
+ * the client drew since the tab.
9
+ */
10
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a harness is a live handle
11
+ export async function openRepoStep(tui: TUIHarness): Promise<void> {
12
+ tui.write('n');
13
+
14
+ await tui.waitFor('spawn: agent');
15
+
16
+ tui.write(KEYS.enter);
17
+
18
+ await tui.waitFor('spawn: directory');
19
+
20
+ tui.reset();
21
+ tui.write(KEYS.tab);
22
+
23
+ await tui.waitFor('spawn: GitHub repository');
24
+ }
@@ -0,0 +1,22 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import type { SQLQueryBindings } from 'bun:sqlite';
3
+ import type { CompiledQuery } from 'kysely';
4
+
5
+ /**
6
+ * Reads the plan SQLite picks for one compiled query against the database
7
+ * at the path, bound to the query's own parameters, as the plan's detail
8
+ * lines joined by newlines. It opens the database read-only and closes it
9
+ * before returning, so the query never runs and nothing is written.
10
+ */
11
+ export function readQueryPlan(dbPath: string, query: CompiledQuery): string {
12
+ using db = new Database(dbPath, { readonly: true });
13
+
14
+ // oxlint-disable-next-line no-unsafe-type-assertion -- kysely's sqlite compiler only ever binds bun:sqlite-legal values
15
+ const bindings = query.parameters as SQLQueryBindings[];
16
+
17
+ return db
18
+ .query<{ detail: string }, SQLQueryBindings[]>(`EXPLAIN QUERY PLAN ${query.sql}`)
19
+ .all(...bindings)
20
+ .map((row) => row.detail)
21
+ .join('\n');
22
+ }
@@ -0,0 +1,63 @@
1
+ import { onTestFinished } from 'bun:test';
2
+
3
+ // The releases the current test registered, in registration order; null
4
+ // before its first registration and once its hook has started.
5
+ let pending: (() => unknown)[] | null = null;
6
+
7
+ /**
8
+ * Registers the release of a resource a test just acquired, to run once the
9
+ * current test finishes, and returns that release for the resource's own
10
+ * stop and dispose members. Every release a test registers runs from one
11
+ * hook, last registered first, as `using` declarations release, so a
12
+ * directory made before a process that lives in it is removed only after
13
+ * that process stops. A release that throws stops none of the others: the
14
+ * hook rethrows once every release has run, as an `AggregateError` when
15
+ * more than one threw. The returned release runs its callback once: every
16
+ * later call, from the hook or from the test, returns what the first call
17
+ * returned. Bun runs the hook after every `afterEach`, so a release works
18
+ * from the paths and handles it captured. Throws outside a test, where
19
+ * nothing would run the hook.
20
+ */
21
+ export function registerTestCleanup<T>(release: () => T): () => T {
22
+ let released: { readonly value: T } | null = null;
23
+
24
+ const releaseOnce = (): T => {
25
+ released ??= { value: release() };
26
+
27
+ return released.value;
28
+ };
29
+
30
+ if (pending === null) {
31
+ onTestFinished(releasePending);
32
+
33
+ pending = [];
34
+ }
35
+
36
+ pending.push(releaseOnce);
37
+
38
+ return releaseOnce;
39
+ }
40
+
41
+ async function releasePending(): Promise<void> {
42
+ const releases = pending ?? [];
43
+
44
+ pending = null;
45
+
46
+ const errors: unknown[] = [];
47
+
48
+ for (const release of releases.toReversed()) {
49
+ try {
50
+ await release();
51
+ } catch (error) {
52
+ errors.push(error);
53
+ }
54
+ }
55
+
56
+ if (errors.length === 1) {
57
+ throw errors[0];
58
+ }
59
+
60
+ if (errors.length > 1) {
61
+ throw new AggregateError(errors, 'more than one test cleanup failed');
62
+ }
63
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from 'node:path';
2
+
3
+ /**
4
+ * The command an end-to-end suite runs atc as: the compiled binary that
5
+ * `ATC_BIN` holds when a smoke run sets it, or else the source entry under
6
+ * the test's own bun, so one suite proves both. Read at each call, so a test
7
+ * that sets the variable sees its own value.
8
+ */
9
+ export function resolveATCCommand(): string[] {
10
+ const bin = process.env['ATC_BIN'];
11
+
12
+ return bin === undefined ? [process.execPath, join(import.meta.dir, '..', 'cli.ts')] : [bin];
13
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from 'node:path';
2
+
3
+ /**
4
+ * The command a test runs `atc-gateway` as: the compiled binary at the given
5
+ * path, which a smoke run of a release build passes in through
6
+ * `ATC_GATEWAY_BIN`, or without one the source entry under the test's own
7
+ * bun, so one gateway suite proves both.
8
+ */
9
+ export function resolveGatewayCommand(binary: string | undefined): readonly string[] {
10
+ return binary === undefined
11
+ ? [process.execPath, join(import.meta.dir, '..', 'gateway.ts')]
12
+ : [binary];
13
+ }
@@ -0,0 +1,56 @@
1
+ interface ATCRun {
2
+ // The command atc runs as, such as the source entry under bun or a
3
+ // compiled binary.
4
+ readonly command: readonly string[];
5
+
6
+ readonly args: readonly string[];
7
+
8
+ // HOME and XDG_RUNTIME_DIR for the run, so it finds the daemon and the
9
+ // state of this home alone.
10
+ readonly home: string;
11
+
12
+ // Variables laid over the inherited environment; `undefined` removes one.
13
+ readonly env?: Readonly<Record<string, string | undefined>>;
14
+
15
+ readonly stdin?: string;
16
+ readonly cwd?: string;
17
+ }
18
+
19
+ interface ATCResult {
20
+ readonly exitCode: number;
21
+ readonly stdout: string;
22
+ readonly stderr: string;
23
+ }
24
+
25
+ /**
26
+ * Runs one atc subcommand as its own process against a home and resolves
27
+ * once it exits, with its exit code and everything it printed. The process
28
+ * inherits the test's environment with HOME and XDG_RUNTIME_DIR pointed at
29
+ * the home, then the run's own variables over that.
30
+ */
31
+ export async function runATC(run: Readonly<ATCRun>): Promise<ATCResult> {
32
+ const env = Object.fromEntries(
33
+ Object.entries({
34
+ ...process.env,
35
+ HOME: run.home,
36
+ XDG_RUNTIME_DIR: run.home,
37
+ ...run.env,
38
+ }).filter((entry): entry is [string, string] => entry[1] !== undefined),
39
+ );
40
+
41
+ const proc = Bun.spawn([...run.command, ...run.args], {
42
+ env,
43
+ ...(run.cwd === undefined ? {} : { cwd: run.cwd }),
44
+ stdin: run.stdin === undefined ? 'ignore' : new TextEncoder().encode(run.stdin),
45
+ stdout: 'pipe',
46
+ stderr: 'pipe',
47
+ });
48
+
49
+ const [stdout, stderr, exitCode] = await Promise.all([
50
+ new Response(proc.stdout).text(),
51
+ new Response(proc.stderr).text(),
52
+ proc.exited,
53
+ ]);
54
+
55
+ return { exitCode, stdout, stderr };
56
+ }
@@ -1,15 +1,19 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { PROTOCOL_V } from '../protocol/protocol';
4
- import { startLegacyDaemon } from './start-legacy-daemon';
4
+ import { startStubLegacyDaemon } from './start-stub-legacy-daemon';
5
5
 
6
6
  /**
7
7
  * Runs a daemon of another protocol version as its own process, recorded in
8
8
  * the state directory the way a real daemon records itself, so a client
9
9
  * that finds it by its record can stop it. Arguments: the socket path to
10
10
  * listen on and the state directory. It refuses every handshake with
11
- * `protocol_mismatch`, prints `up` once it listens, and runs until it is
12
- * killed.
11
+ * `protocol_mismatch` and hosts one session, a child process that sleeps for
12
+ * a minute. Once it listens it prints `up` and the session's pid on one
13
+ * line, and it runs until it is killed. On SIGTERM or SIGINT it kills and
14
+ * reaps the session, then dies of the same signal, and it kills the session
15
+ * whenever it exits, so a caller that stops only the daemon leaves nothing
16
+ * behind.
13
17
  */
14
18
  function main() {
15
19
  const socketPath = process.argv.at(2);
@@ -19,7 +23,25 @@ function main() {
19
23
  throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
20
24
  }
21
25
 
22
- startLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1 });
26
+ startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1, owner: 'caller' });
27
+
28
+ const session = Bun.spawn(['sleep', '60']);
29
+
30
+ process.on('exit', () => {
31
+ session.kill('SIGKILL');
32
+ });
33
+
34
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
35
+ process.once(signal, () => {
36
+ void (async () => {
37
+ session.kill('SIGKILL');
38
+
39
+ await session.exited;
40
+
41
+ process.kill(process.pid, signal);
42
+ })();
43
+ });
44
+ }
23
45
 
24
46
  writeFileSync(
25
47
  join(stateDir, 'daemon.json'),
@@ -32,7 +54,7 @@ function main() {
32
54
  }),
33
55
  );
34
56
 
35
- console.log('up');
57
+ console.log(`up ${session.pid}`);
36
58
  }
37
59
 
38
60
  main();
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { startDaemon } from '../daemon/daemon';
3
+ import { buildMockAgentAdapter } from './build-mock-agent-adapter';
3
4
 
4
5
  /**
5
6
  * Runs a daemon with a TCP listener on a kernel-chosen loopback port, its
@@ -15,17 +16,11 @@ async function main() {
15
16
  socketPath: join(dir, 'daemon.sock'),
16
17
  reporterSocketPath: join(dir, 'reporter.sock'),
17
18
  build: 'atc/test-build',
18
- adapter: {
19
- id: 'claude',
20
- screenDetector: null,
19
+ adapter: buildMockAgentAdapter({
21
20
  takesMessages: true,
22
- headlessRunner: null,
23
- planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
24
21
  normalizeHook: () => ({ kind: 'prompt-submitted' }),
25
- loadName: () => Promise.resolve(null),
26
- canResume: () => true,
27
22
  buildResumeCommand: () => 'claude --resume',
28
- },
23
+ }),
29
24
  dbPath: join(dir, 'state.db'),
30
25
  statusPath: join(dir, 'status.json'),
31
26
  listen: {
@@ -37,8 +32,6 @@ async function main() {
37
32
  },
38
33
  });
39
34
 
40
- process.stdout.write(`${String(handle.listenPort)}\n`);
41
-
42
35
  process.on('SIGTERM', () => {
43
36
  void (async () => {
44
37
  await handle.stop();
@@ -46,6 +39,8 @@ async function main() {
46
39
  process.exit(0);
47
40
  })();
48
41
  });
42
+
43
+ process.stdout.write(`${String(handle.listenPort)}\n`);
49
44
  }
50
45
 
51
46
  await main();
@@ -18,6 +18,9 @@ interface MCPAuthorizationRequest {
18
18
  }
19
19
 
20
20
  interface MCPAuthorization {
21
+ // The consent page the approval code sent the browser to.
22
+ readonly consent: URL;
23
+
21
24
  // Where the consent page sent the browser back to.
22
25
  readonly callback: URL;
23
26
  readonly code: string;
@@ -29,9 +32,9 @@ interface MCPAuthorization {
29
32
  * browser and the operator do: opens the authorization request with PKCE,
30
33
  * types the approval code the server printed into the login page, and ticks
31
34
  * the given scopes on the consent page. Each request starts with no cookies,
32
- * as a fresh browser would. Returns where the browser landed and what a token
33
- * request needs, and throws when any step's response lacks the value the next
34
- * step depends on.
35
+ * as a fresh browser would. Returns the consent page it passed, where the
36
+ * browser landed, and what a token request needs, and throws when any step's
37
+ * response lacks the value the next step depends on.
35
38
  */
36
39
  export async function runMCPAuthorization(
37
40
  server: MCPAuthorizationServer,
@@ -114,5 +117,5 @@ export async function runMCPAuthorization(
114
117
  throw new Error(`the consent redirect holds no authorization code: ${location}`);
115
118
  }
116
119
 
117
- return { callback, code, verifier };
120
+ return { consent, callback, code, verifier };
118
121
  }
@@ -96,8 +96,6 @@ async function main() {
96
96
  gitTransports: config.workspaces.gitTransports,
97
97
  });
98
98
 
99
- process.stdout.write('up\n');
100
-
101
99
  process.on('SIGTERM', () => {
102
100
  void (async () => {
103
101
  await handle.stop();
@@ -105,6 +103,8 @@ async function main() {
105
103
  process.exit(0);
106
104
  })();
107
105
  });
106
+
107
+ process.stdout.write('up\n');
108
108
  }
109
109
 
110
110
  await main();
@@ -0,0 +1,7 @@
1
+ import { faker } from '@faker-js/faker';
2
+
3
+ // Seeds faker once for the whole run and fixes the date its relative dates
4
+ // count from, so a failing run's factory values, dates included, come out
5
+ // the same on the next run.
6
+ faker.seed(48);
7
+ faker.setDefaultRefDate(new Date('2026-01-01T00:00:00.000Z'));
@@ -0,0 +1,35 @@
1
+ // How many connections are open at once, so a flood stays within the
2
+ // host's open-file limit.
3
+ const BATCH = 100;
4
+
5
+ /**
6
+ * Opens `count` TCP connections to the loopback port, each on its own,
7
+ * writes one line that is not a handshake on each, and resolves once the
8
+ * server has closed every one, as a daemon's listener closes a connection
9
+ * it refuses.
10
+ */
11
+ export async function sendLinesBeforeHandshake(port: number, count: number): Promise<void> {
12
+ for (let start = 0; start < count; start += BATCH) {
13
+ await Promise.all(
14
+ Array.from({ length: Math.min(BATCH, count - start) }, async () => {
15
+ const closed = Promise.withResolvers<void>();
16
+
17
+ await Bun.connect({
18
+ hostname: '127.0.0.1',
19
+ port,
20
+ socket: {
21
+ open(socket) {
22
+ socket.write('not a handshake\n');
23
+ },
24
+ data() {},
25
+ close() {
26
+ closed.resolve();
27
+ },
28
+ },
29
+ });
30
+
31
+ await closed.promise;
32
+ }),
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,25 @@
1
+ import { getRecord } from '../shared/get-record';
2
+ import { readJSONRecord } from './read-json-record';
3
+
4
+ /**
5
+ * Posts one JSON-RPC request to an MCP HTTP server's `/mcp` endpoint with
6
+ * the given access token as a bearer token, and returns the response's
7
+ * `result`. Throws when the body is not a JSON object with an object
8
+ * `result`, such as a JSON-RPC error.
9
+ */
10
+ export async function sendMCPRequest(
11
+ url: string,
12
+ token: string,
13
+ method: string,
14
+ params: Readonly<Record<string, unknown>> = {},
15
+ ): Promise<Readonly<Record<string, unknown>>> {
16
+ const answered = await fetch(`${url}/mcp`, {
17
+ method: 'POST',
18
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
19
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
20
+ });
21
+
22
+ const body = await readJSONRecord(answered);
23
+
24
+ return getRecord(body, 'result');
25
+ }
@@ -0,0 +1,110 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { findPidFilePID } from '../shared/find-pid-file-pid';
4
+ import { stopDaemonProcess } from '../stop-daemon-process';
5
+ import { buildStubMCPClaude } from './build-stub-mcp-claude';
6
+ import { buildStubMCPGrok } from './build-stub-mcp-grok';
7
+ import { createStubBin } from './create-stub-bin';
8
+ import { registerTestCleanup } from './register-test-cleanup';
9
+ import { setupTempDir } from './setup-temp-dir';
10
+ import { waitFor } from './wait-for';
11
+
12
+ /**
13
+ * A temp home for running `atc mcp` against, with the stand-in `claude` and
14
+ * `grok` at `claudeBin` and `grokBin` and a config that registers them with
15
+ * no extra arguments. A server started with this home as its `HOME` and
16
+ * `XDG_RUNTIME_DIR` boots its daemon here. Each stand-in appends its pid,
17
+ * which is its process group as a session leader, to `stub-pids` in the
18
+ * home. Disposal stops the daemon, when one wrote its pid file here, and
19
+ * waits for it to exit; then it kills the daemon's process group and every
20
+ * group `stub-pids` records, such as a reporter a stopped session left
21
+ * behind, waits until each group is empty, and removes the home. That
22
+ * disposal runs once the current test finishes, so it must run inside a
23
+ * test; disposing sooner runs it then, and a second disposal does nothing.
24
+ */
25
+ export function setupMCPHome() {
26
+ const tmp = setupTempDir('atc-mcp-');
27
+
28
+ // Registered after the home, so it releases first: the daemon and the
29
+ // stand-ins stop before the home that records them is removed.
30
+ const stack = new AsyncDisposableStack();
31
+
32
+ const dispose = registerTestCleanup(() => stack.disposeAsync());
33
+
34
+ stack.use(tmp);
35
+
36
+ const home = tmp.dir;
37
+
38
+ stack.defer(async () => {
39
+ const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
40
+
41
+ if (pid !== null) {
42
+ await stopDaemonProcess(pid);
43
+ }
44
+
45
+ await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
46
+ });
47
+
48
+ mkdirSync(join(home, '.config', 'atc'), { recursive: true });
49
+ mkdirSync(join(home, '.local', 'state', 'atc'), { recursive: true });
50
+
51
+ const claudeBin = createStubBin(home, 'fake-claude', buildStubMCPClaude());
52
+ const grokBin = createStubBin(home, 'fake-grok', buildStubMCPGrok());
53
+
54
+ writeFileSync(
55
+ join(home, '.config', 'atc', 'config.json'),
56
+ JSON.stringify({ claudeBin, claudeArgs: [], grokBin, grokArgs: [] }),
57
+ );
58
+
59
+ return {
60
+ home,
61
+ claudeBin,
62
+ grokBin,
63
+ [Symbol.asyncDispose]: dispose,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The pids the stand-ins recorded in the home, one per line; none when no
69
+ * stand-in ran.
70
+ */
71
+ function readStubPIDs(home: string): number[] {
72
+ const path = join(home, 'stub-pids');
73
+
74
+ if (!existsSync(path)) {
75
+ return [];
76
+ }
77
+
78
+ return readFileSync(path, 'utf8')
79
+ .split('\n')
80
+ .filter((line) => line !== '')
81
+ .map(Number);
82
+ }
83
+
84
+ async function killProcessGroups(groups: readonly number[]): Promise<void> {
85
+ for (const group of groups) {
86
+ try {
87
+ process.kill(-group, 'SIGKILL');
88
+ } catch {
89
+ // The group is already empty.
90
+ }
91
+ }
92
+
93
+ await waitFor(() => {
94
+ const running = groups.filter((group) => isProcessGroupRunning(group));
95
+
96
+ if (running.length > 0) {
97
+ throw new Error(`process groups still running: ${running.join(', ')}`);
98
+ }
99
+ });
100
+ }
101
+
102
+ function isProcessGroupRunning(group: number): boolean {
103
+ try {
104
+ process.kill(-group, 0);
105
+
106
+ return true;
107
+ } catch (error) {
108
+ return !(error instanceof Error && Reflect.get(error, 'code') === 'ESRCH');
109
+ }
110
+ }