@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
package/src/gateway.ts CHANGED
@@ -1,183 +1,6 @@
1
- import { join } from 'node:path';
2
- import { defineCommand, runMain } from 'citty';
3
- import pkg from '../package.json';
4
- import { collectRedirectURIs } from './collect-redirect-uris';
5
- import { parseGatewayStateDir } from './parse-gateway-state-dir';
6
- import { parsePort } from './parse-port';
7
-
8
- // The flag every subcommand takes for the directory holding `gateway.db` and
9
- // `mcp-auth.db`.
10
- const STATE_DIR_ARG = {
11
- 'state-dir': {
12
- type: 'string',
13
- description: 'Directory for gateway.db and mcp-auth.db (default $ATC_GATEWAY_STATE_DIR)',
14
- },
15
- } as const;
16
-
17
- // Every flag a subcommand declares, so a flag in any position, or one no
18
- // subcommand declares, is caught before a subcommand runs.
19
- const GATEWAY_FLAGS = {
20
- values: new Set(['host', 'port', 'public-url', 'registry', 'redirect-uri', 'state-dir']),
21
- switches: new Set(['help', 'h', 'version']),
22
- };
23
-
24
- // The state directory comes from the whole command line, since the argument
25
- // parser hands a subcommand only the arguments after its name.
26
- const parsedStateDir = parseGatewayStateDir(process.argv.slice(2), process.env, GATEWAY_FLAGS);
27
-
28
- if (!parsedStateDir.ok) {
29
- console.error(`atc-gateway: ${parsedStateDir.message}`);
30
- process.exit(1);
31
- }
32
-
33
- const stateDir = parsedStateDir.stateDir;
34
- const NO_STATE_DIR = 'atc-gateway: give --state-dir or set ATC_GATEWAY_STATE_DIR';
1
+ import { runGatewayCLI } from './run-gateway-cli';
35
2
 
36
3
  // atc-gateway entry: serves the MCP tools over HTTP for the daemons a
37
4
  // registry lists, and manages the clients that may connect to it. It loads
38
5
  // nothing that starts a daemon or a session on this machine.
39
- const main = defineCommand({
40
- meta: {
41
- name: 'atc-gateway',
42
- version: pkg.version,
43
- description: 'Serve the atc MCP tools over HTTP for the daemons a registry lists',
44
- },
45
-
46
- // Declared at every level, so the argument parser reads `--state-dir <dir>`
47
- // before a subcommand as a flag and its value, not as a subcommand name.
48
- args: STATE_DIR_ARG,
49
- subCommands: {
50
- serve: () =>
51
- defineCommand({
52
- meta: { name: 'serve', description: 'Serve MCP over HTTP behind OAuth' },
53
- args: {
54
- host: { type: 'string', description: 'Address to bind (default 127.0.0.1)' },
55
- port: { type: 'string', description: 'Port to listen on (default 8414)' },
56
- 'public-url': {
57
- type: 'string',
58
- required: true,
59
- description: 'Origin clients reach the gateway at, such as https://mcp.example.com',
60
- },
61
- registry: { type: 'string', required: true, description: 'The registry JSON file' },
62
- ...STATE_DIR_ARG,
63
- },
64
- async run(ctx) {
65
- const port = ctx.args.port === undefined ? null : parsePort(ctx.args.port);
66
-
67
- if (port !== null && !port.ok) {
68
- console.error(`atc-gateway: ${port.message}`);
69
- process.exit(1);
70
- }
71
-
72
- if (stateDir === null) {
73
- console.error(NO_STATE_DIR);
74
- process.exit(1);
75
- }
76
-
77
- const gateway = await import('./run-gateway');
78
-
79
- await gateway.runGateway(`atc-gateway/${pkg.version}`, {
80
- host: ctx.args.host ?? '127.0.0.1',
81
- port: port === null ? 8414 : port.port,
82
- publicURL: ctx.args['public-url'],
83
- registryPath: ctx.args.registry,
84
- stateDir,
85
- });
86
- },
87
- }),
88
- clients: () =>
89
- defineCommand({
90
- meta: {
91
- name: 'clients',
92
- description: 'List, add, or remove the clients that may connect to the gateway',
93
- },
94
- args: STATE_DIR_ARG,
95
- default: 'list',
96
- subCommands: {
97
- list: () =>
98
- defineCommand({
99
- meta: { name: 'list', description: 'List the clients', hidden: true },
100
- args: STATE_DIR_ARG,
101
- async run() {
102
- if (stateDir === null) {
103
- console.error(NO_STATE_DIR);
104
- process.exit(1);
105
- }
106
-
107
- const clients = await import('./clients');
108
-
109
- await clients.runClients(
110
- { kind: 'list' },
111
- {
112
- dbPath: join(stateDir, 'mcp-auth.db'),
113
- command: 'atc-gateway clients',
114
- },
115
- );
116
- },
117
- }),
118
- add: () =>
119
- defineCommand({
120
- meta: { name: 'add', description: 'Add a client and print its client ID' },
121
- args: {
122
- name: { type: 'positional', required: true, description: 'The client name' },
123
- 'redirect-uri': {
124
- type: 'string',
125
- required: true,
126
- description: 'A redirect URI the client returns to; repeat for more',
127
- },
128
- ...STATE_DIR_ARG,
129
- },
130
- async run(ctx) {
131
- if (stateDir === null) {
132
- console.error(NO_STATE_DIR);
133
- process.exit(1);
134
- }
135
-
136
- const clients = await import('./clients');
137
-
138
- await clients.runClients(
139
- {
140
- kind: 'add',
141
- name: ctx.args.name,
142
- redirectURIs: collectRedirectURIs(ctx.rawArgs),
143
- },
144
- {
145
- dbPath: join(stateDir, 'mcp-auth.db'),
146
- command: 'atc-gateway clients',
147
- },
148
- );
149
- },
150
- }),
151
- remove: () =>
152
- defineCommand({
153
- meta: {
154
- name: 'remove',
155
- description: 'Remove a client and revoke every grant it holds',
156
- },
157
- args: {
158
- id: { type: 'positional', required: true, description: 'The client ID' },
159
- ...STATE_DIR_ARG,
160
- },
161
- async run(ctx) {
162
- if (stateDir === null) {
163
- console.error(NO_STATE_DIR);
164
- process.exit(1);
165
- }
166
-
167
- const clients = await import('./clients');
168
-
169
- await clients.runClients(
170
- { kind: 'remove', clientID: ctx.args.id },
171
- {
172
- dbPath: join(stateDir, 'mcp-auth.db'),
173
- command: 'atc-gateway clients',
174
- },
175
- );
176
- },
177
- }),
178
- },
179
- }),
180
- },
181
- });
182
-
183
- await runMain(main);
6
+ await runGatewayCLI(process.argv.slice(2), process.env);
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, DaemonPaths } 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 files that locate the daemon it
23
+ // boots or waits for (null for the process's own, the only ones at which it
24
+ // starts a daemon), the console, the exit, and the signal listeners that
25
+ // stop it.
26
+ interface MCPHTTPServerIO {
27
+ readonly loadConfig: () => MCPHTTPConfig;
28
+ readonly dbPath: string;
29
+ readonly daemonPaths: DaemonPaths | null;
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
+ daemonPaths: null,
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,41 @@ 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 waitOptions = 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
+ const bootOptions =
85
+ io.daemonPaths === null ? waitOptions : { ...waitOptions, paths: io.daemonPaths };
86
+
87
+ let boot: DaemonBoot;
88
+
89
+ try {
90
+ boot = await bootDaemonClient(bootOptions);
91
+ } catch (error) {
92
+ io.printError(`atc mcp --http: ${error instanceof Error ? error.message : String(error)}`);
93
+ io.exit(1);
94
+
95
+ return;
96
+ }
37
97
 
38
98
  boot.client.stop();
39
99
 
40
- mkdirSync(stateDir, { recursive: true });
100
+ mkdirSync(dirname(io.dbPath), { recursive: true });
41
101
 
42
102
  const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
43
103
 
@@ -48,45 +108,41 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
48
108
  port: flags.port ?? config.port,
49
109
  publicURL: flags.publicURL ?? config.publicURL,
50
110
  allowedHosts: config.allowedHosts,
51
- dbPath: mcpAuthDBFile,
111
+ dbPath: io.dbPath,
52
112
 
53
113
  // The line carries a client's name, so control and format characters
54
114
  // are dropped before it reaches the operator's terminal.
55
115
  printApproval: (line) => {
56
- console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
116
+ io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
57
117
  },
58
118
 
59
119
  // Request lines go to stderr, so stdout keeps the approval lines alone.
60
120
  printRequest: (line) => {
61
- console.error(line);
121
+ io.printError(line);
62
122
  },
63
123
  });
64
124
 
65
- console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
125
+ // The handlers go in before the serving line, so a signal sent once the
126
+ // line appears always finds them.
127
+ const stopServing = async () => {
128
+ await server.stop();
129
+ await caller.stop();
130
+
131
+ io.exit(0);
132
+ };
66
133
 
67
- const admin = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
134
+ io.registerSignal('SIGINT', stopServing);
135
+ io.registerSignal('SIGTERM', stopServing);
136
+ io.print(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
137
+
138
+ const admin = await openMCPAuth({ dbPath: io.dbPath, origin: null });
68
139
  const clients = await collectClients(admin.db);
69
140
 
70
141
  await admin.close();
71
142
 
72
143
  if (clients.length === 0) {
73
- console.log(
144
+ io.print(
74
145
  'No clients can connect yet. Add one with: atc clients add <name> --redirect-uri <uri>',
75
146
  );
76
147
  }
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
148
  }
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