@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.
- package/package.json +5 -3
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +31 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/render-sdk-message.ts +57 -0
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/start-claude-headless-run.ts +2 -57
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +37 -12
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +122 -39
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/resolve-workspace-root.ts +10 -5
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/session-runtime.ts +0 -4
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/gateway.ts +2 -179
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +88 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway-cli.ts +327 -0
- package/src/run-gateway.ts +51 -18
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +24 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-execution-target.ts +25 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-imp-session-request.ts +53 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +26 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-mock-stored-event.ts +20 -0
- package/src/test-utils/build-mock-stored-row.ts +23 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-recording-filter.ts +9 -0
- package/src/test-utils/build-stub-recording-git.ts +8 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +117 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-echo-claude.ts +21 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/create-stub-recording-codex.ts +20 -0
- package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/register-test-cleanup.ts +63 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +110 -0
- package/src/test-utils/setup-mcp-http.ts +26 -30
- package/src/test-utils/setup-temp-dir.ts +7 -6
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +29 -8
- package/src/test-utils/start-daemon-process.ts +193 -0
- package/src/test-utils/start-git-http-server.ts +28 -4
- package/src/test-utils/start-mcp-stdio.ts +190 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-answering-listener.ts +65 -0
- package/src/test-utils/start-stub-closing-listener.ts +31 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
- package/src/test-utils/start-stub-impd-info.ts +38 -0
- package/src/test-utils/start-stub-impd.ts +143 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
- package/src/test-utils/start-stub-mcp-server.ts +55 -0
- package/src/test-utils/start-stub-recording-listener.ts +47 -0
- package/src/test-utils/start-stub-refusing-listener.ts +43 -0
- package/src/test-utils/start-stub-reporter-socket.ts +79 -0
- package/src/test-utils/start-stub-restore-daemon.ts +59 -0
- package/src/test-utils/start-stub-session-bridge.ts +100 -0
- package/src/test-utils/start-stub-silent-server.ts +38 -0
- package/src/test-utils/start-stub-source-daemon.ts +41 -0
- package/src/test-utils/start-stub-stalled-client.ts +61 -0
- package/src/test-utils/start-stub-stalled-listener.ts +44 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
- package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
- package/src/test-utils/start-test-daemon.ts +229 -0
- package/src/test-utils/start-tui-harness.ts +291 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +30 -17
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- 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
|
+
}
|