@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,190 @@
1
+ import { join } from 'node:path';
2
+ import { isRecord } from '../shared/report';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
6
+
7
+ interface MCPStdioOptions {
8
+ // The home the server runs with, as its `HOME` and `XDG_RUNTIME_DIR`.
9
+ readonly home: string;
10
+
11
+ // The session id the server sees itself running inside; absent means a
12
+ // server started outside any session.
13
+ readonly callerSessionID?: string;
14
+
15
+ // A command that stands in for `atc mcp`; absent means this checkout's
16
+ // `atc mcp`.
17
+ readonly command?: readonly string[];
18
+ }
19
+
20
+ /**
21
+ * One tool call's result as the server returned it: `isError` as sent,
22
+ * `structured` as its `structuredContent`, and the text of its only content
23
+ * item.
24
+ */
25
+ interface MCPToolResult {
26
+ readonly isError: unknown;
27
+ readonly text: string;
28
+ readonly structured: Readonly<Record<string, unknown>> | undefined;
29
+ }
30
+
31
+ /**
32
+ * `atc mcp` from this checkout, run as a subprocess over stdio, with an
33
+ * `initialize` already answered, so the daemon the server boots or joins is
34
+ * up. `sendRequest` sends one JSON-RPC request under the next id and
35
+ * resolves with its whole response. `sendToolCall` sends `tools/call` and
36
+ * resolves with the result, rejecting when the response holds no result, the
37
+ * result holds anything but one text item, or its structured content is not
38
+ * an object. `spawnSession` spawns through `atc_session_spawn` and resolves
39
+ * with the new session's id. A request still unanswered when the server's
40
+ * stdout ends rejects. When `initialize` fails, the server is stopped
41
+ * before the start rejects. The server stops once the current test
42
+ * finishes, so it must run inside a test; disposal stops it sooner and waits
43
+ * for it to exit, and a second disposal waits for the same exit. The daemon
44
+ * stays up for the home to stop.
45
+ */
46
+ export async function startMCPStdio(options: MCPStdioOptions) {
47
+ const proc = Bun.spawn([...(options.command ?? [process.execPath, CLI_PATH, 'mcp'])], {
48
+ env: {
49
+ ...process.env,
50
+ HOME: options.home,
51
+ XDG_RUNTIME_DIR: options.home,
52
+ PATH: '/usr/sbin:/usr/bin:/bin',
53
+ ATC_SESSION_ID: options.callerSessionID ?? '',
54
+ ATC_TAP_GRACE_MS: '0',
55
+ },
56
+ stdin: 'pipe',
57
+ stdout: 'pipe',
58
+ stderr: 'ignore',
59
+ });
60
+
61
+ const stopServer = registerTestCleanup(async (): Promise<void> => {
62
+ void proc.stdin.end();
63
+ proc.kill();
64
+
65
+ await proc.exited;
66
+ });
67
+
68
+ const pending = new Map<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>();
69
+
70
+ void readResponses(proc.stdout, pending);
71
+ let nextID = 0;
72
+
73
+ const sendRequest = (
74
+ method: string,
75
+ params?: Readonly<Record<string, unknown>>,
76
+ ): Promise<Readonly<Record<string, unknown>>> => {
77
+ nextID += 1;
78
+
79
+ const id = nextID;
80
+ const response = Promise.withResolvers<Readonly<Record<string, unknown>>>();
81
+
82
+ pending.set(id, response);
83
+
84
+ void proc.stdin.write(
85
+ `${JSON.stringify({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) })}\n`,
86
+ );
87
+
88
+ void proc.stdin.flush();
89
+
90
+ return response.promise;
91
+ };
92
+
93
+ const sendToolCall = async (
94
+ name: string,
95
+ args: Readonly<Record<string, unknown>>,
96
+ ): Promise<MCPToolResult> => {
97
+ const response = await sendRequest('tools/call', { name, arguments: args });
98
+
99
+ return toToolResult(response);
100
+ };
101
+
102
+ const server = {
103
+ sendRequest,
104
+ sendToolCall,
105
+ async spawnSession(args: Readonly<Record<string, unknown>>): Promise<string> {
106
+ const spawned = await sendToolCall('atc_session_spawn', args);
107
+
108
+ const id = spawned.structured?.['id'];
109
+
110
+ if (typeof id !== 'string') {
111
+ throw new TypeError(`spawn returned no session id: ${spawned.text}`);
112
+ }
113
+
114
+ return id;
115
+ },
116
+ [Symbol.asyncDispose]: stopServer,
117
+ };
118
+
119
+ // A failed initialize stops the server here, before the start rejects.
120
+ await sendRequest('initialize', {
121
+ protocolVersion: '2025-06-18',
122
+ capabilities: {},
123
+ clientInfo: { name: 'atc-test' },
124
+ }).catch(async (error: unknown) => {
125
+ await stopServer();
126
+
127
+ throw error;
128
+ });
129
+
130
+ return server;
131
+ }
132
+
133
+ /**
134
+ * Reads the server's stdout as JSON-RPC lines and resolves the pending
135
+ * request each response's id matches. Once stdout ends, or holds a line that
136
+ * is not JSON, every request not yet resolved rejects.
137
+ */
138
+ async function readResponses(
139
+ stdout: Readonly<ReadableStream<Uint8Array>>,
140
+ pending: ReadonlyMap<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>,
141
+ ): Promise<void> {
142
+ const decoder = new TextDecoder('utf-8');
143
+
144
+ let buffer = '';
145
+
146
+ try {
147
+ for await (const chunk of stdout) {
148
+ buffer += decoder.decode(chunk, { stream: true });
149
+
150
+ const lines = buffer.split('\n');
151
+
152
+ buffer = lines.pop() ?? '';
153
+
154
+ for (const line of lines.filter((candidate) => candidate.trim() !== '')) {
155
+ const parsed: unknown = JSON.parse(line);
156
+ const id = isRecord(parsed) ? parsed['id'] : undefined;
157
+ const waiting = typeof id === 'number' ? pending.get(id) : undefined;
158
+
159
+ if (isRecord(parsed) && waiting !== undefined) {
160
+ waiting.resolve(parsed);
161
+ }
162
+ }
163
+ }
164
+ } catch {
165
+ // A line that is not JSON ends the reading; the requests still pending
166
+ // reject below.
167
+ } finally {
168
+ for (const [id, waiting] of pending) {
169
+ waiting.reject(new Error(`atc mcp stopped answering before request ${id}`));
170
+ }
171
+ }
172
+ }
173
+
174
+ function toToolResult(response: Readonly<Record<string, unknown>>): MCPToolResult {
175
+ const result = response['result'];
176
+ const content = isRecord(result) ? result['content'] : undefined;
177
+ const item: unknown = Array.isArray(content) && content.length === 1 ? content[0] : undefined;
178
+ const structured = isRecord(result) ? result['structuredContent'] : undefined;
179
+
180
+ if (
181
+ !isRecord(result) ||
182
+ !isRecord(item) ||
183
+ typeof item['text'] !== 'string' ||
184
+ (structured !== undefined && !isRecord(structured))
185
+ ) {
186
+ throw new TypeError(`tool call returned an unexpected result: ${JSON.stringify(response)}`);
187
+ }
188
+
189
+ return { isError: result['isError'], text: item['text'], structured };
190
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Reads the stream in the background and keeps every chunk it has read, as
3
+ * text, in arrival order, until the stream ends. `read` returns all the text
4
+ * read so far, so a test can wait on output from a process that never
5
+ * exits.
6
+ */
7
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- reading a stream locks and drains it
8
+ export function startOutputCapture(stream: ReadableStream<Uint8Array>) {
9
+ let text = '';
10
+
11
+ const decoder = new TextDecoder();
12
+
13
+ void (async () => {
14
+ for await (const chunk of stream) {
15
+ text += decoder.decode(chunk, { stream: true });
16
+ }
17
+ })();
18
+
19
+ return { read: () => text };
20
+ }
@@ -0,0 +1,65 @@
1
+ import { createServer } from 'node:net';
2
+ import type { Socket } from 'node:net';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ interface StubAnsweringListener {
6
+ // The server's side of each connection it accepted, in order.
7
+ readonly peers: readonly Socket[];
8
+
9
+ // The first read each connection sent, decoded, in arrival order.
10
+ readonly lines: readonly string[];
11
+ readonly [Symbol.asyncDispose]: () => Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * A stand-in for a daemon socket that answers a handshake: it listens on
16
+ * the unix socket path, records each connection it accepts and the first
17
+ * read each one sends, answers that read with the line `answer`, and
18
+ * leaves every later byte unread on the connection. Resolves once it
19
+ * listens. Once the current test finishes, it destroys every connection it
20
+ * accepted and closes, so it must run inside a test; disposal does so
21
+ * sooner and resolves once the server has closed, and a second stop does
22
+ * nothing.
23
+ */
24
+ export async function startStubAnsweringListener(path: string): Promise<StubAnsweringListener> {
25
+ const peers: Socket[] = [];
26
+ const lines: string[] = [];
27
+
28
+ const server = createServer((peer) => {
29
+ peers.push(peer);
30
+
31
+ peer.once('data', (data) => {
32
+ peer.pause();
33
+ lines.push(data.toString());
34
+ peer.write('answer\n');
35
+ });
36
+ });
37
+
38
+ const listening = Promise.withResolvers<void>();
39
+
40
+ server.listen(path, () => {
41
+ listening.resolve();
42
+ });
43
+
44
+ await listening.promise;
45
+
46
+ const stop = registerTestCleanup(async () => {
47
+ for (const peer of peers) {
48
+ peer.destroy();
49
+ }
50
+
51
+ const closed = Promise.withResolvers<void>();
52
+
53
+ server.close(() => {
54
+ closed.resolve();
55
+ });
56
+
57
+ await closed.promise;
58
+ });
59
+
60
+ return {
61
+ peers,
62
+ lines,
63
+ [Symbol.asyncDispose]: stop,
64
+ };
65
+ }
@@ -0,0 +1,31 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ interface StubClosingListener {
4
+ readonly [Symbol.dispose]: () => void;
5
+ }
6
+
7
+ /**
8
+ * A stand-in for a daemon that hangs up: it listens on the unix socket path
9
+ * and ends every connection as soon as it opens, reading nothing. It stops
10
+ * once the current test finishes, so it must run inside a test; disposal
11
+ * stops it sooner, and a second stop does nothing.
12
+ */
13
+ export function startStubClosingListener(path: string): StubClosingListener {
14
+ const server = Bun.listen({
15
+ unix: path,
16
+ socket: {
17
+ open(socket) {
18
+ socket.end();
19
+ },
20
+ data() {},
21
+ },
22
+ });
23
+
24
+ const stop = registerTestCleanup(() => {
25
+ server.stop(true);
26
+ });
27
+
28
+ return {
29
+ [Symbol.dispose]: stop,
30
+ };
31
+ }
@@ -0,0 +1,105 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+ import {
3
+ MAX_CHUNK,
4
+ MAX_LINE,
5
+ PROTOCOL_V,
6
+ decodeMessage,
7
+ encodeMessage,
8
+ } from '../protocol/protocol';
9
+ import { registerTestCleanup } from './register-test-cleanup';
10
+
11
+ interface DroppingDaemonOptions {
12
+ // The features the first handshake announces.
13
+ readonly features: readonly DaemonFeature[];
14
+
15
+ // The features every later handshake announces, as a daemon restarted on
16
+ // another build would; the first handshake's features when absent.
17
+ readonly retryFeatures?: readonly DaemonFeature[];
18
+ }
19
+
20
+ /**
21
+ * A daemon on a unix socket whose connection drops mid-request: it answers
22
+ * each handshake as a daemon does, announcing the features the options give, ends the connection
23
+ * that carries the first request of any other method without answering it,
24
+ * and answers every later one with a session `s-1`. `keys` records the
25
+ * idempotency key each of those requests carried, `undefined` for one that
26
+ * carried none. `reads` counts the reads it has taken from every connection,
27
+ * so a test can wait until one piece of a split write has arrived. It stops
28
+ * once the current test finishes, so it must run inside a test; `stop` or
29
+ * disposal stops it sooner, and a second stop does nothing.
30
+ */
31
+ export function startStubDroppingDaemon(socketPath: string, options: DroppingDaemonOptions) {
32
+ const keys: unknown[] = [];
33
+ let hellos = 0;
34
+ let reads = 0;
35
+
36
+ const server = Bun.listen<{ buffer: string }>({
37
+ unix: socketPath,
38
+ socket: {
39
+ open(socket) {
40
+ socket.data = { buffer: '' };
41
+ },
42
+ data(socket, buf) {
43
+ reads += 1;
44
+
45
+ const lines = `${socket.data.buffer}${buf.toString()}`.split('\n');
46
+
47
+ socket.data.buffer = lines.pop() ?? '';
48
+
49
+ for (const line of lines) {
50
+ const decoded = decodeMessage(line);
51
+
52
+ if (decoded.kind !== 'request') {
53
+ continue;
54
+ }
55
+
56
+ if (decoded.msg.m === 'daemon.hello') {
57
+ hellos += 1;
58
+
59
+ const announced =
60
+ hellos === 1 ? options.features : (options.retryFeatures ?? options.features);
61
+
62
+ socket.write(
63
+ encodeMessage({
64
+ v: PROTOCOL_V,
65
+ id: decoded.msg.id,
66
+ ok: {
67
+ daemon: 'atc/dropping-build',
68
+ limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
69
+ lastUsedAgent: 'claude',
70
+ features: announced,
71
+ },
72
+ }),
73
+ );
74
+
75
+ continue;
76
+ }
77
+
78
+ keys.push(decoded.msg.p?.['idempotencyKey']);
79
+
80
+ if (keys.length === 1) {
81
+ socket.end();
82
+ continue;
83
+ }
84
+
85
+ socket.write(
86
+ encodeMessage({ v: PROTOCOL_V, id: decoded.msg.id, ok: { session: { id: 's-1' } } }),
87
+ );
88
+ }
89
+ },
90
+ },
91
+ });
92
+
93
+ const stop = registerTestCleanup(() => {
94
+ server.stop(true);
95
+ });
96
+
97
+ return {
98
+ keys,
99
+ get reads() {
100
+ return reads;
101
+ },
102
+ stop,
103
+ [Symbol.dispose]: stop,
104
+ };
105
+ }
@@ -0,0 +1,38 @@
1
+ import { registerTestCleanup } from './register-test-cleanup';
2
+
3
+ /**
4
+ * An impd stand-in on a real HTTP port of the loopback address. Every call
5
+ * is answered with HTTP 200 and the body impd's RPC sends for system info,
6
+ * `{ json: info }`, so a client reads `info.features` as impd's features;
7
+ * the test sets `info.features` to what impd should report. `paths` holds
8
+ * the request path of each call, in order. The server stops once the
9
+ * current test finishes, so it must run inside a test; disposal stops it
10
+ * sooner, the asynchronous form resolving once it has stopped, and a second
11
+ * stop does nothing.
12
+ */
13
+ export function startStubImpdInfo() {
14
+ const paths: string[] = [];
15
+ const info: { features: unknown } = { features: undefined };
16
+
17
+ const server = Bun.serve({
18
+ port: 0,
19
+ hostname: '127.0.0.1',
20
+ fetch: (request) => {
21
+ paths.push(new URL(request.url).pathname);
22
+
23
+ return Response.json({ json: info });
24
+ },
25
+ });
26
+
27
+ const stop = registerTestCleanup(() => server.stop(true));
28
+
29
+ return {
30
+ url: `http://127.0.0.1:${String(server.port)}`,
31
+ paths,
32
+ info,
33
+ [Symbol.dispose]: () => {
34
+ void stop();
35
+ },
36
+ [Symbol.asyncDispose]: stop,
37
+ };
38
+ }
@@ -0,0 +1,143 @@
1
+ import type { ServerWebSocket } from 'bun';
2
+ import { isRecord } from '../shared/report';
3
+ import { registerTestCleanup } from './register-test-cleanup';
4
+
5
+ // What the stand-in does with an exec open: refuse it as a start whose
6
+ // broker is not ready, start it and count its stdin bytes as `wc -c` does,
7
+ // or start it and exit 2 at once without reading stdin.
8
+ type StubExecReply = 'refuse' | 'count' | 'exit-early';
9
+
10
+ /**
11
+ * Starts an impd stand-in on a real HTTP port. It records the authorization
12
+ * header of each call and WebSocket upgrade, and the path and input of each
13
+ * RPC call. It answers a call with the answer the caller set for its path in
14
+ * `answers`, or else as system info, and answers a tunnel listen as
15
+ * listening, keeping that control socket in `controls` so a caller can
16
+ * announce a guest connection on it. It records each exec open message in
17
+ * `execOpens` and answers it as `exec.reply` says, a refusal as a start
18
+ * whose broker is not ready by default. It takes no WebSocket message over
19
+ * 2 MiB, as impd refuses one over its own limit. The server stops, dropping
20
+ * every open connection at once, when the current test finishes, so it must
21
+ * run inside a test; disposal stops it sooner, and a second stop does
22
+ * nothing.
23
+ */
24
+ export function startStubImpd() {
25
+ const authorizations: (string | null)[] = [];
26
+ const calls: { path: string; input: unknown }[] = [];
27
+
28
+ const answers = new Map<string, { status: number; json: unknown }>();
29
+
30
+ const controls: ServerWebSocket[] = [];
31
+ const execOpens: unknown[] = [];
32
+ const exec: { reply: StubExecReply } = { reply: 'refuse' };
33
+
34
+ const counted = new WeakMap<ServerWebSocket, number>();
35
+
36
+ const server = Bun.serve({
37
+ port: 0,
38
+ hostname: '127.0.0.1',
39
+ fetch: async (request, bunServer) => {
40
+ authorizations.push(request.headers.get('authorization'));
41
+
42
+ const path = new URL(request.url).pathname;
43
+
44
+ const isUpgraded = (path === '/tunnel' || path === '/exec') && bunServer.upgrade(request);
45
+ const text = isUpgraded ? '' : await request.text();
46
+ const body: unknown = text === '' ? null : JSON.parse(text);
47
+
48
+ if (!isUpgraded) {
49
+ calls.push({ path, input: isRecord(body) ? body['json'] : undefined });
50
+ }
51
+
52
+ const answer = answers.get(path) ?? {
53
+ status: 200,
54
+ json: { features: { sessionOffsets: true, leases: true } },
55
+ };
56
+
57
+ return isUpgraded
58
+ ? undefined
59
+ : Response.json({ json: answer.json }, { status: answer.status });
60
+ },
61
+ websocket: {
62
+ maxPayloadLength: 2 * 1024 * 1024,
63
+ message: (socket, message) => {
64
+ const seen = counted.get(socket);
65
+
66
+ if (typeof message !== 'string') {
67
+ if (seen !== undefined) {
68
+ counted.set(socket, seen + message.byteLength - 1);
69
+ }
70
+
71
+ return;
72
+ }
73
+
74
+ const parsed: unknown = JSON.parse(message);
75
+
76
+ if (!isRecord(parsed)) {
77
+ return;
78
+ }
79
+
80
+ if (parsed['type'] === 'stdin_eof' && seen !== undefined) {
81
+ const count = new TextEncoder().encode(`${String(seen)}\n`);
82
+
83
+ socket.send(new Uint8Array([1, ...count]));
84
+ socket.send(JSON.stringify({ type: 'exit', code: 0, signal: null }));
85
+
86
+ return;
87
+ }
88
+
89
+ if (parsed['type'] === 'start' || parsed['type'] === 'attach') {
90
+ execOpens.push(parsed);
91
+
92
+ if (exec.reply === 'refuse') {
93
+ socket.send(
94
+ JSON.stringify({
95
+ type: 'error',
96
+ code: 'PRECONDITION_FAILED',
97
+ message: 'the broker is not ready',
98
+ data: { reason: 'broker_not_ready', detail: 'the broker CA did not install' },
99
+ }),
100
+ );
101
+
102
+ return;
103
+ }
104
+
105
+ socket.send(JSON.stringify({ type: 'started', pid: 7 }));
106
+
107
+ if (exec.reply === 'count') {
108
+ counted.set(socket, 0);
109
+
110
+ return;
111
+ }
112
+
113
+ socket.send(JSON.stringify({ type: 'exit', code: 2, signal: null }));
114
+
115
+ return;
116
+ }
117
+
118
+ if (parsed['type'] === 'listen') {
119
+ controls.push(socket);
120
+
121
+ socket.send(
122
+ JSON.stringify({ type: 'listening', listener: 'l1', path: '/tmp/r.sock', port: null }),
123
+ );
124
+ }
125
+ },
126
+ },
127
+ });
128
+
129
+ const stop = registerTestCleanup(() => server.stop(true));
130
+
131
+ return {
132
+ url: `http://127.0.0.1:${String(server.port)}`,
133
+ authorizations,
134
+ calls,
135
+ answers,
136
+ controls,
137
+ execOpens,
138
+ exec,
139
+ [Symbol.dispose]: () => {
140
+ void stop();
141
+ },
142
+ };
143
+ }