@zgeoff/atc 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +62 -13
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
package/src/grants.ts CHANGED
@@ -1,33 +1,58 @@
1
1
  import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
2
3
  import { collectGrants } from './mcp/collect-grants';
3
4
  import { openMCPAuth } from './mcp/open-mcp-auth';
4
5
  import { revokeGrant } from './mcp/revoke-grant';
5
- import { mcpAuthDBFile, stateDir } from './shared/config';
6
+ import { mcpAuthDBFile } from './shared/config';
7
+
8
+ // Where the command's lines and its exit code go: the console and the
9
+ // process by default.
10
+ interface GrantsIO {
11
+ readonly print: (line: string) => void;
12
+ readonly printError: (line: string) => void;
13
+ readonly setExitCode: (code: number) => void;
14
+ }
15
+
16
+ const PROCESS_IO: GrantsIO = {
17
+ print: (line) => {
18
+ console.log(line);
19
+ },
20
+ printError: (line) => {
21
+ console.error(line);
22
+ },
23
+ setExitCode: (code) => {
24
+ process.exitCode = code;
25
+ },
26
+ };
6
27
 
7
28
  /**
8
29
  * Runs `atc grants`: lists the grants clients of `atc mcp --http` hold, or
9
30
  * revokes one. A revoked grant's tokens stop working at once, and its client
10
31
  * has to go through approval again. It opens the authorization server's
11
- * database directly, so it works whether or not the server is running.
32
+ * database at `dbPath` directly, so it works whether or not the server is
33
+ * running. Revoking a grant that does not exist sets exit code 1.
12
34
  */
13
- export async function runGrants(revoke: string | null): Promise<void> {
14
- mkdirSync(stateDir, { recursive: true });
35
+ export async function runGrants(
36
+ revoke: string | null,
37
+ dbPath: string = mcpAuthDBFile,
38
+ io: GrantsIO = PROCESS_IO,
39
+ ): Promise<void> {
40
+ mkdirSync(dirname(dbPath), { recursive: true });
15
41
 
16
- const store = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
42
+ const store = await openMCPAuth({ dbPath, origin: null });
17
43
 
18
44
  try {
19
45
  if (revoke !== null) {
20
46
  const revoked = await revokeGrant(store.db, revoke);
21
47
 
22
48
  if (!revoked) {
23
- console.error(`atc grants: no grant has the ID '${revoke}'`);
24
-
25
- process.exitCode = 1;
49
+ io.printError(`atc grants: no grant has the ID '${revoke}'`);
50
+ io.setExitCode(1);
26
51
 
27
52
  return;
28
53
  }
29
54
 
30
- console.log(`Revoked grant ${revoke}`);
55
+ io.print(`Revoked grant ${revoke}`);
31
56
 
32
57
  return;
33
58
  }
@@ -35,13 +60,13 @@ export async function runGrants(revoke: string | null): Promise<void> {
35
60
  const grants = await collectGrants(store.db);
36
61
 
37
62
  if (grants.length === 0) {
38
- console.log('No grants.');
63
+ io.print('No grants.');
39
64
 
40
65
  return;
41
66
  }
42
67
 
43
68
  for (const grant of grants) {
44
- console.log(
69
+ io.print(
45
70
  `${grant.grantID} ${grant.clientName} ${grant.scopes.join(',')} last used ${grant.lastUsedAt ?? 'never'}`,
46
71
  );
47
72
  }
@@ -1,6 +1,20 @@
1
1
  import { normalizeHookEventName } from './agents/normalize-hook-event';
2
2
  import { isRecord, sendReport } from './shared/report';
3
3
 
4
+ // Where the reporter reads its event and how it exits: stdin and the
5
+ // process by default.
6
+ interface HookReportIO {
7
+ readonly readStdin: () => Promise<string>;
8
+ readonly exit: (code: number) => void;
9
+ }
10
+
11
+ const PROCESS_IO: HookReportIO = {
12
+ readStdin: () => new Response(Bun.stdin.stream()).text(),
13
+ exit: (code) => {
14
+ process.exit(code);
15
+ },
16
+ };
17
+
4
18
  /**
5
19
  * Runs as a hook inside wrangled sessions. Reads the hook event from stdin
6
20
  * (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
@@ -9,12 +23,12 @@ import { isRecord, sendReport } from './shared/report';
9
23
  * session's own. Always exits 0 so it never blocks the session it reports
10
24
  * on.
11
25
  */
12
- export async function runHookReport(agent: string): Promise<void> {
26
+ export async function runHookReport(agent: string, io: HookReportIO = PROCESS_IO): Promise<void> {
13
27
  const sock = process.env['ATC_SOCKET'];
14
28
  const atcId = process.env['ATC_SESSION_ID'];
15
29
 
16
30
  if (sock !== undefined && sock !== '' && atcId !== undefined && atcId !== '') {
17
- const raw = await new Response(Bun.stdin.stream()).text();
31
+ const raw = await io.readStdin();
18
32
 
19
33
  let payload: Record<string, unknown> = {};
20
34
 
@@ -33,5 +47,5 @@ export async function runHookReport(agent: string): Promise<void> {
33
47
  await sendReport(sock, line, 2000);
34
48
  }
35
49
 
36
- process.exit(0);
50
+ io.exit(0);
37
51
  }
@@ -1,4 +1,6 @@
1
1
  import { isRecord } from '../shared/report';
2
+ import { systemClock } from '../shared/system-clock';
3
+ import type { Clock } from '../shared/system-clock';
2
4
  import type { FleetCaller } from './types';
3
5
 
4
6
  // The most report text one events page carries, in UTF-8 bytes: as much as
@@ -26,33 +28,34 @@ type ReportRead = Readonly<Record<string, unknown>>;
26
28
  * deadline: it then holds the cursor of the last event it keeps and `more`
27
29
  * true, so the next read starts at that report. Every other field of the
28
30
  * page, such as a gateway's `unavailable` and `truncated`, passes through
29
- * unchanged.
31
+ * unchanged. The deadline runs on `clock`, the wall clock by default.
30
32
  */
31
33
  export async function readReportTexts(
32
34
  caller: FleetCaller,
33
35
  page: Readonly<Record<string, unknown>>,
34
36
  deadlineMs: number = REPORT_READ_DEADLINE_MS,
37
+ clock: Clock = systemClock,
35
38
  ): Promise<Readonly<Record<string, unknown>>> {
36
39
  const raw: unknown = page['events'];
37
40
  const events = Array.isArray(raw) ? raw.filter((event) => isRecord(event)) : [];
38
- const endsAt = Date.now() + deadlineMs;
41
+ const endsAt = clock.now() + deadlineMs;
39
42
  const timeout = Promise.withResolvers<ReportRead>();
40
43
 
41
- const timer = setTimeout(() => {
44
+ const cancel = clock.schedule(() => {
42
45
  timeout.resolve({
43
46
  textError: `timeout: the report text did not arrive within ${deadlineMs} ms`,
44
47
  });
45
48
  }, deadlineMs);
46
49
 
47
- const read = await readPageReports(caller, page, events, endsAt, timeout.promise);
50
+ const read = await readPageReports(caller, page, events, endsAt, timeout.promise, clock);
48
51
 
49
- clearTimeout(timer);
52
+ cancel();
50
53
 
51
54
  return read;
52
55
  }
53
56
 
54
57
  // The page with its reports read, one at a time, until the budget or the
55
- // deadline at `endsAt` stops it. A read still out when `timeout` settles
58
+ // deadline at `endsAt` on `clock` stops it. A read still out when `timeout` settles
56
59
  // gives way to the timeout it settles with.
57
60
  async function readPageReports(
58
61
  caller: FleetCaller,
@@ -60,6 +63,7 @@ async function readPageReports(
60
63
  events: readonly Readonly<Record<string, unknown>>[],
61
64
  endsAt: number,
62
65
  timeout: Readonly<Promise<ReportRead>>,
66
+ clock: Clock,
63
67
  ): Promise<Readonly<Record<string, unknown>>> {
64
68
  const kept: Readonly<Record<string, unknown>>[] = [];
65
69
  let used = 0;
@@ -72,7 +76,7 @@ async function readPageReports(
72
76
 
73
77
  const last = kept.at(-1);
74
78
 
75
- if (last !== undefined && Date.now() >= endsAt) {
79
+ if (last !== undefined && clock.now() >= endsAt) {
76
80
  return { ...page, events: kept, cursor: last['cursor'], more: true };
77
81
  }
78
82
 
@@ -38,6 +38,10 @@ interface MCPHTTPServerOptions {
38
38
 
39
39
  // Serves `/healthz` and `/readyz` for an orchestrator's probes.
40
40
  readonly probes?: boolean;
41
+
42
+ // The time pending approvals and the limit on approval starts run on, in
43
+ // epoch milliseconds; the wall clock when unset.
44
+ readonly now?: () => number;
41
45
  }
42
46
 
43
47
  /**
@@ -171,7 +175,7 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
171
175
  origin,
172
176
  resource: `${origin}/mcp`,
173
177
  store,
174
- approvals: new ApprovalState(600_000, () => Date.now()),
178
+ approvals: new ApprovalState(600_000, options.now ?? (() => Date.now())),
175
179
  printApproval: options.printApproval,
176
180
  },
177
181
  };
@@ -1,11 +1,14 @@
1
1
  import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
2
3
  import { bootDaemonClient } from './client/boot-daemon';
4
+ import type { DaemonBoot, DaemonBootOptions } from './client/boot-daemon';
3
5
  import { DaemonClient } from './client/daemon-client';
4
6
  import { collectClients } from './mcp/collect-clients';
5
7
  import { openMCPAuth } from './mcp/open-mcp-auth';
6
8
  import { ReconnectingCaller } from './mcp/reconnecting-caller';
7
9
  import { startMCPHTTPServer } from './mcp/start-mcp-http-server';
8
- import { mcpAuthDBFile, stateDir } from './shared/config';
10
+ import type { MCPHTTPConfig } from './shared/collect-mcp-http-config';
11
+ import { mcpAuthDBFile } from './shared/config';
9
12
  import { loadMCPHTTPConfig } from './shared/load-mcp-http-config';
10
13
 
11
14
  interface MCPHTTPFlags {
@@ -15,6 +18,41 @@ interface MCPHTTPFlags {
15
18
  readonly waitForDaemon: boolean;
16
19
  }
17
20
 
21
+ // What the server reaches outside itself, the process's own by default: its
22
+ // config, the authorization database, the daemon it boots or waits for, the
23
+ // console, the exit, and the signal listeners that stop it.
24
+ interface MCPHTTPServerIO {
25
+ readonly loadConfig: () => MCPHTTPConfig;
26
+ readonly dbPath: string;
27
+ readonly bootDaemon: (
28
+ options: DaemonBootOptions,
29
+ ) => Promise<Pick<DaemonBoot, 'client' | 'socketPath'>>;
30
+ readonly print: (line: string) => void;
31
+ readonly printError: (line: string) => void;
32
+ readonly exit: (code: number) => void;
33
+ readonly registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => Promise<void>) => void;
34
+ }
35
+
36
+ const PROCESS_IO: MCPHTTPServerIO = {
37
+ loadConfig: loadMCPHTTPConfig,
38
+ dbPath: mcpAuthDBFile,
39
+ bootDaemon: bootDaemonClient,
40
+ print: (line) => {
41
+ console.log(line);
42
+ },
43
+ printError: (line) => {
44
+ console.error(line);
45
+ },
46
+ exit: (code) => {
47
+ process.exit(code);
48
+ },
49
+ registerSignal: (signal, listener) => {
50
+ process.on(signal, () => {
51
+ void listener();
52
+ });
53
+ },
54
+ };
55
+
18
56
  // How long `--wait-for-daemon` waits before it exits. A service manager's
19
57
  // restart covers a daemon that takes longer.
20
58
  const DAEMON_WAIT_MS = 30_000;
@@ -25,19 +63,38 @@ const DAEMON_WAIT_MS = 30_000;
25
63
  * serves MCP over HTTP until Ctrl-C. Approval codes print here, so the
26
64
  * terminal running it is where the operator approves a client.
27
65
  */
28
- export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Promise<void> {
29
- const config = loadMCPHTTPConfig();
30
- const bootOptions = flags.waitForDaemon ? { waitForDaemonMs: DAEMON_WAIT_MS } : {};
31
-
32
- const boot = await bootDaemonClient(bootOptions).catch((error: unknown) => {
33
- console.error(`atc mcp --http: ${error instanceof Error ? error.message : String(error)}`);
34
-
35
- return process.exit(1);
36
- });
66
+ export async function runMCPHTTPServer(
67
+ build: string,
68
+ flags: MCPHTTPFlags,
69
+ io: MCPHTTPServerIO = PROCESS_IO,
70
+ ): Promise<void> {
71
+ const config = io.loadConfig();
72
+
73
+ const bootOptions = flags.waitForDaemon
74
+ ? {
75
+ waitForDaemonMs: DAEMON_WAIT_MS,
76
+ onWaitForDaemon: () => {
77
+ io.printError(
78
+ `atc mcp --http: no daemon answers yet; waiting up to ${DAEMON_WAIT_MS / 1000}s for one, without starting it`,
79
+ );
80
+ },
81
+ }
82
+ : {};
83
+
84
+ let boot: Pick<DaemonBoot, 'client' | 'socketPath'>;
85
+
86
+ try {
87
+ boot = await io.bootDaemon(bootOptions);
88
+ } catch (error) {
89
+ io.printError(`atc mcp --http: ${error instanceof Error ? error.message : String(error)}`);
90
+ io.exit(1);
91
+
92
+ return;
93
+ }
37
94
 
38
95
  boot.client.stop();
39
96
 
40
- mkdirSync(stateDir, { recursive: true });
97
+ mkdirSync(dirname(io.dbPath), { recursive: true });
41
98
 
42
99
  const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
43
100
 
@@ -48,45 +105,41 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
48
105
  port: flags.port ?? config.port,
49
106
  publicURL: flags.publicURL ?? config.publicURL,
50
107
  allowedHosts: config.allowedHosts,
51
- dbPath: mcpAuthDBFile,
108
+ dbPath: io.dbPath,
52
109
 
53
110
  // The line carries a client's name, so control and format characters
54
111
  // are dropped before it reaches the operator's terminal.
55
112
  printApproval: (line) => {
56
- console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
113
+ io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
57
114
  },
58
115
 
59
116
  // Request lines go to stderr, so stdout keeps the approval lines alone.
60
117
  printRequest: (line) => {
61
- console.error(line);
118
+ io.printError(line);
62
119
  },
63
120
  });
64
121
 
65
- console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
122
+ // The handlers go in before the serving line, so a signal sent once the
123
+ // line appears always finds them.
124
+ const stopServing = async () => {
125
+ await server.stop();
126
+ await caller.stop();
127
+
128
+ io.exit(0);
129
+ };
66
130
 
67
- const admin = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
131
+ io.registerSignal('SIGINT', stopServing);
132
+ io.registerSignal('SIGTERM', stopServing);
133
+ io.print(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
134
+
135
+ const admin = await openMCPAuth({ dbPath: io.dbPath, origin: null });
68
136
  const clients = await collectClients(admin.db);
69
137
 
70
138
  await admin.close();
71
139
 
72
140
  if (clients.length === 0) {
73
- console.log(
141
+ io.print(
74
142
  'No clients can connect yet. Add one with: atc clients add <name> --redirect-uri <uri>',
75
143
  );
76
144
  }
77
-
78
- const stopServing = async () => {
79
- await server.stop();
80
- await caller.stop();
81
-
82
- process.exit(0);
83
- };
84
-
85
- process.on('SIGINT', () => {
86
- void stopServing();
87
- });
88
-
89
- process.on('SIGTERM', () => {
90
- void stopServing();
91
- });
92
145
  }
package/src/report.ts CHANGED
@@ -16,6 +16,20 @@ interface ReportOptions {
16
16
  readonly turn: string;
17
17
  }
18
18
 
19
+ // Where the reporter reads its text and how it exits: stdin and the process
20
+ // by default.
21
+ interface ReportIO {
22
+ readonly readStdin: () => Promise<string>;
23
+ readonly exit: (code: number) => void;
24
+ }
25
+
26
+ const PROCESS_IO: ReportIO = {
27
+ readStdin: () => new Response(Bun.stdin.stream()).text(),
28
+ exit: (code) => {
29
+ process.exit(code);
30
+ },
31
+ };
32
+
19
33
  /**
20
34
  * Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
21
35
  * atc socket as a Report envelope of the given kind. An `answered` report
@@ -26,7 +40,11 @@ interface ReportOptions {
26
40
  * given. Inside a remote host it goes to the session bridge instead. Always
27
41
  * exits 0 so it never blocks the session it reports on.
28
42
  */
29
- export async function runReport(kind: string, options: ReportOptions): Promise<void> {
43
+ export async function runReport(
44
+ kind: string,
45
+ options: ReportOptions,
46
+ io: ReportIO = PROCESS_IO,
47
+ ): Promise<void> {
30
48
  try {
31
49
  const sock = process.env['ATC_SOCKET'];
32
50
  const atcId = process.env['ATC_SESSION_ID'];
@@ -38,7 +56,7 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
38
56
  atcId !== '' &&
39
57
  REPORT_KINDS.some((known) => known === kind)
40
58
  ) {
41
- const stdin = await new Response(Bun.stdin.stream()).text();
59
+ const stdin = await io.readStdin();
42
60
 
43
61
  const payload = buildReportPayload(kind, options, stdin);
44
62
 
@@ -52,7 +70,7 @@ export async function runReport(kind: string, options: ReportOptions): Promise<v
52
70
  }
53
71
  } catch {}
54
72
 
55
- process.exit(0);
73
+ io.exit(0);
56
74
  }
57
75
 
58
76
  // Inside a remote host, a report goes to the session bridge under an id of
@@ -29,6 +29,24 @@ const SEEN_LIMIT = 10_000;
29
29
  // opened the tap.
30
30
  type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
31
31
 
32
+ // Where the tap prints and how it exits: stdout, stderr, and the process by
33
+ // default.
34
+ interface BridgeTapIO {
35
+ readonly writeStdout: (text: string) => Promise<unknown>;
36
+ readonly printError: (line: string) => void;
37
+ readonly exit: (code: number) => void;
38
+ }
39
+
40
+ const PROCESS_IO: BridgeTapIO = {
41
+ writeStdout: (text) => Bun.write(Bun.stdout, text),
42
+ printError: (line) => {
43
+ console.error(line);
44
+ },
45
+ exit: (code) => {
46
+ process.exit(code);
47
+ },
48
+ };
49
+
32
50
  /**
33
51
  * Streams the session's inbox to stdout from inside a remote host, through
34
52
  * the session bridge at the given socket. It prints each message once as
@@ -40,21 +58,29 @@ type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
40
58
  * outbox still holds. Exits 0 once the inbox closes and 1 when the bridge
41
59
  * refuses the tap.
42
60
  */
43
- export async function runBridgeTap(socketPath: string, outbox: string): Promise<void> {
61
+ export async function runBridgeTap(
62
+ socketPath: string,
63
+ outbox: string,
64
+ io: BridgeTapIO = PROCESS_IO,
65
+ ): Promise<void> {
44
66
  const seen = new Set<string>();
45
67
 
46
68
  let wait = FIRST_RETRY_MS;
47
69
 
48
70
  for (;;) {
49
- const end = await runTapConnection(socketPath, outbox, seen);
71
+ const end = await runTapConnection(socketPath, outbox, seen, io);
50
72
 
51
73
  if (end === 'closed') {
52
- process.exit(0);
74
+ io.exit(0);
75
+
76
+ return;
53
77
  }
54
78
 
55
79
  if (end === 'refused') {
56
- console.error('atc tap: the session bridge refused the tap');
57
- process.exit(1);
80
+ io.printError('atc tap: the session bridge refused the tap');
81
+ io.exit(1);
82
+
83
+ return;
58
84
  }
59
85
 
60
86
  if (end === 'lost') {
@@ -73,6 +99,7 @@ async function runTapConnection(
73
99
 
74
100
  // oxlint-disable-next-line prefer-readonly-parameter-types -- the ids the tap printed, which every connection adds to
75
101
  seen: Set<string>,
102
+ io: BridgeTapIO,
76
103
  ): Promise<TapEnd> {
77
104
  const ended = Promise.withResolvers<TapEnd>();
78
105
  let opened = false;
@@ -139,9 +166,11 @@ async function runTapConnection(
139
166
  const printed = `${JSON.stringify({ id: message.message, from: message.from, text: message.text, sentAt: message.sentAt })}\n`;
140
167
 
141
168
  try {
142
- await Bun.write(Bun.stdout, printed);
169
+ await io.writeStdout(printed);
143
170
  } catch {
144
- process.exit(1);
171
+ io.exit(1);
172
+
173
+ return;
145
174
  }
146
175
 
147
176
  updateSeenIDs(seen, message.message);
@@ -1,19 +1,43 @@
1
1
  import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { buildMigratedConfig } from './build-migrated-config';
3
3
 
4
+ // Where the command's output goes, the console by default: text printed as
5
+ // it is, and lines printed to stdout or stderr.
6
+ interface ConfigMigrateIO {
7
+ readonly printText: (text: string) => void;
8
+ readonly print: (line: string) => void;
9
+ readonly printError: (line: string) => void;
10
+ }
11
+
12
+ const CONSOLE_IO: ConfigMigrateIO = {
13
+ printText: (text) => {
14
+ process.stdout.write(text);
15
+ },
16
+ print: (line) => {
17
+ console.log(line);
18
+ },
19
+ printError: (line) => {
20
+ console.error(line);
21
+ },
22
+ };
23
+
4
24
  /**
5
25
  * Runs `atc config migrate`: prints the config.json rewritten around
6
26
  * `agents`, or with `write` copies the file to a timestamped backup beside it
7
27
  * and rewrites it in place. Returns the exit code. Notes and problems go to
8
28
  * stderr and name keys and paths, never values.
9
29
  */
10
- export function runConfigMigrate(file: string, write: boolean): number {
30
+ export function runConfigMigrate(
31
+ file: string,
32
+ write: boolean,
33
+ io: ConfigMigrateIO = CONSOLE_IO,
34
+ ): number {
11
35
  let text: string;
12
36
 
13
37
  try {
14
38
  text = readFileSync(file, 'utf8');
15
39
  } catch {
16
- console.error(`atc config migrate: ${file} cannot be read`);
40
+ io.printError(`atc config migrate: ${file} cannot be read`);
17
41
 
18
42
  return 1;
19
43
  }
@@ -23,7 +47,7 @@ export function runConfigMigrate(file: string, write: boolean): number {
23
47
  try {
24
48
  raw = JSON.parse(text);
25
49
  } catch {
26
- console.error(`atc config migrate: ${file} is not valid JSON`);
50
+ io.printError(`atc config migrate: ${file} is not valid JSON`);
27
51
 
28
52
  return 1;
29
53
  }
@@ -31,23 +55,23 @@ export function runConfigMigrate(file: string, write: boolean): number {
31
55
  const result = buildMigratedConfig(raw);
32
56
 
33
57
  if (result.kind === 'unusable') {
34
- console.error(`atc config migrate: ${file}: ${result.detail}`);
58
+ io.printError(`atc config migrate: ${file}: ${result.detail}`);
35
59
 
36
60
  return 1;
37
61
  }
38
62
 
39
63
  if (result.kind === 'current') {
40
- console.log('config.json already uses agents; nothing to migrate');
64
+ io.print('config.json already uses agents; nothing to migrate');
41
65
 
42
66
  return 0;
43
67
  }
44
68
 
45
69
  for (const note of result.notes) {
46
- console.error(note);
70
+ io.printError(note);
47
71
  }
48
72
 
49
73
  if (!write) {
50
- process.stdout.write(result.text);
74
+ io.printText(result.text);
51
75
 
52
76
  return 0;
53
77
  }
@@ -57,7 +81,7 @@ export function runConfigMigrate(file: string, write: boolean): number {
57
81
  try {
58
82
  copyFileSync(file, backup);
59
83
  } catch {
60
- console.error(`atc config migrate: ${backup} cannot be written; ${file} is unchanged`);
84
+ io.printError(`atc config migrate: ${backup} cannot be written; ${file} is unchanged`);
61
85
 
62
86
  return 1;
63
87
  }
@@ -65,13 +89,13 @@ export function runConfigMigrate(file: string, write: boolean): number {
65
89
  try {
66
90
  writeFileSync(file, result.text);
67
91
  } catch {
68
- console.error(`atc config migrate: ${file} cannot be written; the backup is at ${backup}`);
92
+ io.printError(`atc config migrate: ${file} cannot be written; the backup is at ${backup}`);
69
93
 
70
94
  return 1;
71
95
  }
72
96
 
73
- console.log(`backup: ${backup}`);
74
- console.log(`wrote: ${file}`);
97
+ io.print(`backup: ${backup}`);
98
+ io.print(`wrote: ${file}`);
75
99
 
76
100
  return 0;
77
101
  }
@@ -74,8 +74,8 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
74
74
  process.exit(1);
75
75
  }
76
76
 
77
- console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
78
-
77
+ // The handlers go in before the serving line, so a signal sent once the
78
+ // line appears always finds them.
79
79
  const stopServing = async () => {
80
80
  await server.stop();
81
81
  await gateway.stop();
@@ -90,4 +90,6 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
90
90
  process.on('SIGTERM', () => {
91
91
  void stopServing();
92
92
  });
93
+
94
+ console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
93
95
  }
@@ -4,10 +4,14 @@ import { isCompiledBinary } from './is-compiled-binary';
4
4
  /**
5
5
  * The argv that runs this build of atc with `args`: the compiled binary is
6
6
  * its own entry, and a source run is bun with the CLI entrypoint ahead of
7
- * the arguments.
7
+ * the arguments. `compiled` tells the two apart, and defaults to how this
8
+ * process runs.
8
9
  */
9
- export function buildATCCommand(args: readonly string[]): string[] {
10
- return isCompiledBinary()
10
+ export function buildATCCommand(
11
+ args: readonly string[],
12
+ compiled: boolean = isCompiledBinary(),
13
+ ): string[] {
14
+ return compiled
11
15
  ? [process.execPath, ...args]
12
16
  : [process.execPath, join(import.meta.dir, '..', 'cli.ts'), ...args];
13
17
  }
@@ -16,6 +16,12 @@ export class BunSqliteDriver implements Driver {
16
16
 
17
17
  private releaseTurn: () => void = () => {};
18
18
 
19
+ /**
20
+ * How many acquires are waiting for an earlier caller to release the
21
+ * connection.
22
+ */
23
+ waiting = 0;
24
+
19
25
  constructor(sqlite: Database) {
20
26
  this.connection = buildConnection(sqlite);
21
27
  }
@@ -32,8 +38,12 @@ export class BunSqliteDriver implements Driver {
32
38
  release = resolve;
33
39
  });
34
40
 
41
+ this.waiting += 1;
42
+
35
43
  await previous;
36
44
 
45
+ this.waiting -= 1;
46
+
37
47
  if (release !== undefined) {
38
48
  this.releaseTurn = release;
39
49
  }