@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.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -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/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- 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/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/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/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 +85 -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.ts +4 -2
- 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 +15 -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-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-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -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-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-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/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- 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-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-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 +120 -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-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/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- 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/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-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 +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- 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 +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- 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 +62 -13
- 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,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,103 @@
|
|
|
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
|
+
|
|
10
|
+
interface DroppingDaemonOptions {
|
|
11
|
+
// The features the first handshake announces.
|
|
12
|
+
readonly features: readonly DaemonFeature[];
|
|
13
|
+
|
|
14
|
+
// The features every later handshake announces, as a daemon restarted on
|
|
15
|
+
// another build would; the first handshake's features when absent.
|
|
16
|
+
readonly retryFeatures?: readonly DaemonFeature[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A daemon on a unix socket whose connection drops mid-request: it answers
|
|
21
|
+
* each handshake as a daemon does, announcing the features the options give, ends the connection
|
|
22
|
+
* that carries the first request of any other method without answering it,
|
|
23
|
+
* and answers every later one with a session `s-1`. `keys` records the
|
|
24
|
+
* idempotency key each of those requests carried, `undefined` for one that
|
|
25
|
+
* carried none. `reads` counts the reads it has taken from every connection,
|
|
26
|
+
* so a test can wait until one piece of a split write has arrived. Stop it
|
|
27
|
+
* with `stop`, or hold it with `using`.
|
|
28
|
+
*/
|
|
29
|
+
export function startStubDroppingDaemon(socketPath: string, options: DroppingDaemonOptions) {
|
|
30
|
+
const keys: unknown[] = [];
|
|
31
|
+
let hellos = 0;
|
|
32
|
+
let reads = 0;
|
|
33
|
+
|
|
34
|
+
const server = Bun.listen<{ buffer: string }>({
|
|
35
|
+
unix: socketPath,
|
|
36
|
+
socket: {
|
|
37
|
+
open(socket) {
|
|
38
|
+
socket.data = { buffer: '' };
|
|
39
|
+
},
|
|
40
|
+
data(socket, buf) {
|
|
41
|
+
reads += 1;
|
|
42
|
+
|
|
43
|
+
const lines = `${socket.data.buffer}${buf.toString()}`.split('\n');
|
|
44
|
+
|
|
45
|
+
socket.data.buffer = lines.pop() ?? '';
|
|
46
|
+
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
const decoded = decodeMessage(line);
|
|
49
|
+
|
|
50
|
+
if (decoded.kind !== 'request') {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (decoded.msg.m === 'daemon.hello') {
|
|
55
|
+
hellos += 1;
|
|
56
|
+
|
|
57
|
+
const announced =
|
|
58
|
+
hellos === 1 ? options.features : (options.retryFeatures ?? options.features);
|
|
59
|
+
|
|
60
|
+
socket.write(
|
|
61
|
+
encodeMessage({
|
|
62
|
+
v: PROTOCOL_V,
|
|
63
|
+
id: decoded.msg.id,
|
|
64
|
+
ok: {
|
|
65
|
+
daemon: 'atc/dropping-build',
|
|
66
|
+
limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
|
|
67
|
+
lastUsedAgent: 'claude',
|
|
68
|
+
features: announced,
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
keys.push(decoded.msg.p?.['idempotencyKey']);
|
|
77
|
+
|
|
78
|
+
if (keys.length === 1) {
|
|
79
|
+
socket.end();
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
socket.write(
|
|
84
|
+
encodeMessage({ v: PROTOCOL_V, id: decoded.msg.id, ok: { session: { id: 's-1' } } }),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
keys,
|
|
93
|
+
get reads() {
|
|
94
|
+
return reads;
|
|
95
|
+
},
|
|
96
|
+
stop() {
|
|
97
|
+
server.stop(true);
|
|
98
|
+
},
|
|
99
|
+
[Symbol.dispose]() {
|
|
100
|
+
server.stop(true);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An impd stand-in on a real HTTP port of the loopback address. Every call
|
|
3
|
+
* is answered with HTTP 200 and the body impd's RPC sends for system info,
|
|
4
|
+
* `{ json: info }`, so a client reads `info.features` as impd's features;
|
|
5
|
+
* the test sets `info.features` to what impd should report. `paths` holds
|
|
6
|
+
* the request path of each call, in order. Disposal stops the server; the
|
|
7
|
+
* asynchronous form resolves once it has stopped.
|
|
8
|
+
*/
|
|
9
|
+
export function startStubImpdInfo() {
|
|
10
|
+
const paths: string[] = [];
|
|
11
|
+
const info: { features: unknown } = { features: undefined };
|
|
12
|
+
|
|
13
|
+
const server = Bun.serve({
|
|
14
|
+
port: 0,
|
|
15
|
+
hostname: '127.0.0.1',
|
|
16
|
+
fetch: (request) => {
|
|
17
|
+
paths.push(new URL(request.url).pathname);
|
|
18
|
+
|
|
19
|
+
return Response.json({ json: info });
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
url: `http://127.0.0.1:${String(server.port)}`,
|
|
25
|
+
paths,
|
|
26
|
+
info,
|
|
27
|
+
[Symbol.dispose]: () => {
|
|
28
|
+
void server.stop(true);
|
|
29
|
+
},
|
|
30
|
+
[Symbol.asyncDispose]: () => server.stop(true),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ServerWebSocket } from 'bun';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
|
|
4
|
+
// What the stand-in does with an exec open: refuse it as a start whose
|
|
5
|
+
// broker is not ready, start it and count its stdin bytes as `wc -c` does,
|
|
6
|
+
// or start it and exit 2 at once without reading stdin.
|
|
7
|
+
type StubExecReply = 'refuse' | 'count' | 'exit-early';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Starts an impd stand-in on a real HTTP port. It records the authorization
|
|
11
|
+
* header of each call and WebSocket upgrade, and the path and input of each
|
|
12
|
+
* RPC call. It answers a call with the answer the caller set for its path in
|
|
13
|
+
* `answers`, or else as system info, and answers a tunnel listen as
|
|
14
|
+
* listening, keeping that control socket in `controls` so a caller can
|
|
15
|
+
* announce a guest connection on it. It records each exec open message in
|
|
16
|
+
* `execOpens` and answers it as `exec.reply` says, a refusal as a start
|
|
17
|
+
* whose broker is not ready by default. It takes no WebSocket message over
|
|
18
|
+
* 2 MiB, as impd refuses one over its own limit. Disposal stops the server
|
|
19
|
+
* and drops every open connection at once.
|
|
20
|
+
*/
|
|
21
|
+
export function startStubImpd() {
|
|
22
|
+
const authorizations: (string | null)[] = [];
|
|
23
|
+
const calls: { path: string; input: unknown }[] = [];
|
|
24
|
+
|
|
25
|
+
const answers = new Map<string, { status: number; json: unknown }>();
|
|
26
|
+
|
|
27
|
+
const controls: ServerWebSocket[] = [];
|
|
28
|
+
const execOpens: unknown[] = [];
|
|
29
|
+
const exec: { reply: StubExecReply } = { reply: 'refuse' };
|
|
30
|
+
|
|
31
|
+
const counted = new WeakMap<ServerWebSocket, number>();
|
|
32
|
+
|
|
33
|
+
const server = Bun.serve({
|
|
34
|
+
port: 0,
|
|
35
|
+
hostname: '127.0.0.1',
|
|
36
|
+
fetch: async (request, bunServer) => {
|
|
37
|
+
authorizations.push(request.headers.get('authorization'));
|
|
38
|
+
|
|
39
|
+
const path = new URL(request.url).pathname;
|
|
40
|
+
|
|
41
|
+
const isUpgraded = (path === '/tunnel' || path === '/exec') && bunServer.upgrade(request);
|
|
42
|
+
const text = isUpgraded ? '' : await request.text();
|
|
43
|
+
const body: unknown = text === '' ? null : JSON.parse(text);
|
|
44
|
+
|
|
45
|
+
if (!isUpgraded) {
|
|
46
|
+
calls.push({ path, input: isRecord(body) ? body['json'] : undefined });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const answer = answers.get(path) ?? {
|
|
50
|
+
status: 200,
|
|
51
|
+
json: { features: { sessionOffsets: true, leases: true } },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return isUpgraded
|
|
55
|
+
? undefined
|
|
56
|
+
: Response.json({ json: answer.json }, { status: answer.status });
|
|
57
|
+
},
|
|
58
|
+
websocket: {
|
|
59
|
+
maxPayloadLength: 2 * 1024 * 1024,
|
|
60
|
+
message: (socket, message) => {
|
|
61
|
+
const seen = counted.get(socket);
|
|
62
|
+
|
|
63
|
+
if (typeof message !== 'string') {
|
|
64
|
+
if (seen !== undefined) {
|
|
65
|
+
counted.set(socket, seen + message.byteLength - 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parsed: unknown = JSON.parse(message);
|
|
72
|
+
|
|
73
|
+
if (!isRecord(parsed)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (parsed['type'] === 'stdin_eof' && seen !== undefined) {
|
|
78
|
+
const count = new TextEncoder().encode(`${String(seen)}\n`);
|
|
79
|
+
|
|
80
|
+
socket.send(new Uint8Array([1, ...count]));
|
|
81
|
+
socket.send(JSON.stringify({ type: 'exit', code: 0, signal: null }));
|
|
82
|
+
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (parsed['type'] === 'start' || parsed['type'] === 'attach') {
|
|
87
|
+
execOpens.push(parsed);
|
|
88
|
+
|
|
89
|
+
if (exec.reply === 'refuse') {
|
|
90
|
+
socket.send(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
type: 'error',
|
|
93
|
+
code: 'PRECONDITION_FAILED',
|
|
94
|
+
message: 'the broker is not ready',
|
|
95
|
+
data: { reason: 'broker_not_ready', detail: 'the broker CA did not install' },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
socket.send(JSON.stringify({ type: 'started', pid: 7 }));
|
|
103
|
+
|
|
104
|
+
if (exec.reply === 'count') {
|
|
105
|
+
counted.set(socket, 0);
|
|
106
|
+
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
socket.send(JSON.stringify({ type: 'exit', code: 2, signal: null }));
|
|
111
|
+
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (parsed['type'] === 'listen') {
|
|
116
|
+
controls.push(socket);
|
|
117
|
+
|
|
118
|
+
socket.send(
|
|
119
|
+
JSON.stringify({ type: 'listening', listener: 'l1', path: '/tmp/r.sock', port: null }),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
url: `http://127.0.0.1:${String(server.port)}`,
|
|
128
|
+
authorizations,
|
|
129
|
+
calls,
|
|
130
|
+
answers,
|
|
131
|
+
controls,
|
|
132
|
+
execOpens,
|
|
133
|
+
exec,
|
|
134
|
+
[Symbol.dispose]: () => {
|
|
135
|
+
void server.stop(true);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { SocketHandler } from 'bun';
|
|
2
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
|
+
import {
|
|
4
|
+
MAX_CHUNK,
|
|
5
|
+
MAX_LINE,
|
|
6
|
+
PROTOCOL_V,
|
|
7
|
+
decodeMessage,
|
|
8
|
+
encodeMessage,
|
|
9
|
+
} from '../protocol/protocol';
|
|
10
|
+
|
|
11
|
+
interface ReceivedRequest {
|
|
12
|
+
readonly m: string;
|
|
13
|
+
readonly p: Readonly<Record<string, unknown>> | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A TCP address to listen on in place of a unix socket; port 0 lets the
|
|
17
|
+
// kernel choose one.
|
|
18
|
+
interface StubLegacyDaemonTCPAddress {
|
|
19
|
+
readonly hostname: string;
|
|
20
|
+
readonly port: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StubLegacyDaemonOptions {
|
|
24
|
+
readonly features?: readonly DaemonFeature[];
|
|
25
|
+
|
|
26
|
+
// The protocol version the daemon speaks; a hello on any other version is
|
|
27
|
+
// refused with `protocol_mismatch`, as a real daemon refuses it.
|
|
28
|
+
readonly protocol?: number;
|
|
29
|
+
|
|
30
|
+
readonly replies?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An older atc daemon listening on a unix socket, or on the TCP address
|
|
35
|
+
* given in its place, and speaking the current
|
|
36
|
+
* protocol version, or the one `protocol` holds. Its `daemon.hello` announces `features` when the options
|
|
37
|
+
* hold that list, and announces none without it, as a daemon from before
|
|
38
|
+
* features did. It answers `daemon.hello`, `daemon.ping`, and each method in
|
|
39
|
+
* `replies` with that method's reply, and refuses every other method with
|
|
40
|
+
* `unknown_method`. Every request it receives is recorded in `requests`, and
|
|
41
|
+
* `connections` counts the connections it accepted and those still open.
|
|
42
|
+
* `port` holds the TCP port it bound, `null` on a unix socket. Stop
|
|
43
|
+
* it with `stop`, or hold it with `using`.
|
|
44
|
+
*/
|
|
45
|
+
export function startStubLegacyDaemon(
|
|
46
|
+
address: string | StubLegacyDaemonTCPAddress,
|
|
47
|
+
options: StubLegacyDaemonOptions = {},
|
|
48
|
+
) {
|
|
49
|
+
const answers: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
|
50
|
+
'daemon.hello': {
|
|
51
|
+
daemon: 'atc/legacy-build',
|
|
52
|
+
limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
|
|
53
|
+
lastUsedAgent: 'claude',
|
|
54
|
+
...(options.features === undefined ? {} : { features: options.features }),
|
|
55
|
+
},
|
|
56
|
+
'daemon.ping': {},
|
|
57
|
+
...options.replies,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const protocol = options.protocol ?? PROTOCOL_V;
|
|
61
|
+
const requests: ReceivedRequest[] = [];
|
|
62
|
+
const connections = { accepted: 0, open: 0 };
|
|
63
|
+
|
|
64
|
+
const handlers: SocketHandler<{ buffer: string }> = {
|
|
65
|
+
open(socket) {
|
|
66
|
+
socket.data = { buffer: '' };
|
|
67
|
+
connections.accepted += 1;
|
|
68
|
+
connections.open += 1;
|
|
69
|
+
},
|
|
70
|
+
close() {
|
|
71
|
+
connections.open -= 1;
|
|
72
|
+
},
|
|
73
|
+
data(socket, chunk) {
|
|
74
|
+
const lines = `${socket.data.buffer}${chunk.toString()}`.split('\n');
|
|
75
|
+
|
|
76
|
+
socket.data.buffer = lines.pop() ?? '';
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const decoded = decodeMessage(line);
|
|
80
|
+
|
|
81
|
+
if (decoded.kind !== 'request') {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const req = decoded.msg;
|
|
86
|
+
|
|
87
|
+
requests.push({ m: req.m, p: req.p });
|
|
88
|
+
|
|
89
|
+
if (req.m === 'daemon.hello' && req.v !== protocol) {
|
|
90
|
+
const client = req.p?.['client'];
|
|
91
|
+
|
|
92
|
+
socket.write(
|
|
93
|
+
encodeMessage({
|
|
94
|
+
v: protocol,
|
|
95
|
+
id: req.id,
|
|
96
|
+
err: {
|
|
97
|
+
code: 'protocol_mismatch',
|
|
98
|
+
msg: `${String(client)} speaks protocol v${req.v}, daemon atc/legacy-build speaks v${protocol}; restart the daemon so both run the same build`,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const answer = answers[req.m];
|
|
107
|
+
|
|
108
|
+
const reply =
|
|
109
|
+
answer === undefined
|
|
110
|
+
? {
|
|
111
|
+
v: protocol,
|
|
112
|
+
id: req.id,
|
|
113
|
+
err: { code: 'unknown_method' as const, msg: `unknown method '${req.m}'` },
|
|
114
|
+
}
|
|
115
|
+
: { v: protocol, id: req.id, ok: answer };
|
|
116
|
+
|
|
117
|
+
socket.write(encodeMessage(reply));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const server = startListener(address, handlers);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
requests,
|
|
126
|
+
connections,
|
|
127
|
+
port: server.port,
|
|
128
|
+
stop() {
|
|
129
|
+
server.stop();
|
|
130
|
+
},
|
|
131
|
+
[Symbol.dispose]() {
|
|
132
|
+
server.stop();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Listens on the unix socket path, or on the TCP address in its place, and
|
|
138
|
+
// returns the port bound, `null` for a unix socket.
|
|
139
|
+
function startListener(
|
|
140
|
+
address: string | StubLegacyDaemonTCPAddress,
|
|
141
|
+
handlers: Readonly<SocketHandler<{ buffer: string }>>,
|
|
142
|
+
): { readonly port: number | null; readonly stop: () => void } {
|
|
143
|
+
if (typeof address === 'string') {
|
|
144
|
+
const unix = Bun.listen<{ buffer: string }>({ unix: address, socket: handlers });
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
port: null,
|
|
148
|
+
stop: () => {
|
|
149
|
+
unix.stop(true);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tcp = Bun.listen<{ buffer: string }>({
|
|
155
|
+
hostname: address.hostname,
|
|
156
|
+
port: address.port,
|
|
157
|
+
socket: handlers,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
port: tcp.port,
|
|
162
|
+
stop: () => {
|
|
163
|
+
tcp.stop(true);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
interface StubMCPRequest {
|
|
2
|
+
readonly method: string;
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly authorization: string | null;
|
|
5
|
+
readonly type: string | null;
|
|
6
|
+
readonly body: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface StubMCPServer {
|
|
10
|
+
readonly url: string;
|
|
11
|
+
|
|
12
|
+
// Every request the stand-in received, in arrival order.
|
|
13
|
+
readonly requests: readonly StubMCPRequest[];
|
|
14
|
+
|
|
15
|
+
readonly [Symbol.dispose]: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A stand-in for an MCP HTTP server on a loopback port: it answers every
|
|
20
|
+
* request with the JSON body given, and records each request's method,
|
|
21
|
+
* path, authorization and content-type headers, and JSON body. `url` holds
|
|
22
|
+
* its origin. Disposal stops it; hold the result with `using`.
|
|
23
|
+
*/
|
|
24
|
+
export function startStubMCPServer(answer: unknown): StubMCPServer {
|
|
25
|
+
const requests: StubMCPRequest[] = [];
|
|
26
|
+
|
|
27
|
+
const server = Bun.serve({
|
|
28
|
+
hostname: '127.0.0.1',
|
|
29
|
+
port: 0,
|
|
30
|
+
async fetch(request) {
|
|
31
|
+
requests.push({
|
|
32
|
+
method: request.method,
|
|
33
|
+
path: new URL(request.url).pathname,
|
|
34
|
+
authorization: request.headers.get('authorization'),
|
|
35
|
+
type: request.headers.get('content-type'),
|
|
36
|
+
body: await request.json(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return Response.json(answer);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
url: `http://127.0.0.1:${String(server.port)}`,
|
|
45
|
+
requests,
|
|
46
|
+
[Symbol.dispose]: () => {
|
|
47
|
+
void server.stop(true);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
interface StubRefusingListener {
|
|
2
|
+
readonly port: number;
|
|
3
|
+
|
|
4
|
+
// What each read took from any connection, decoded, in arrival order.
|
|
5
|
+
readonly received: readonly string[];
|
|
6
|
+
|
|
7
|
+
readonly [Symbol.dispose]: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A stand-in for a daemon's TCP listener refusing a connection whose first
|
|
12
|
+
* line is not a handshake: on a loopback port, it records what each read
|
|
13
|
+
* takes from a connection and ends that connection, answering nothing.
|
|
14
|
+
* Disposal stops it; hold the result with `using`.
|
|
15
|
+
*/
|
|
16
|
+
export function startStubRefusingListener(): StubRefusingListener {
|
|
17
|
+
const received: string[] = [];
|
|
18
|
+
|
|
19
|
+
const server = Bun.listen({
|
|
20
|
+
hostname: '127.0.0.1',
|
|
21
|
+
port: 0,
|
|
22
|
+
socket: {
|
|
23
|
+
data(socket, data) {
|
|
24
|
+
received.push(data.toString());
|
|
25
|
+
socket.end();
|
|
26
|
+
},
|
|
27
|
+
error() {},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
port: server.port,
|
|
33
|
+
received,
|
|
34
|
+
[Symbol.dispose]: () => {
|
|
35
|
+
server.stop(true);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { waitFor } from './wait-for';
|
|
2
|
+
|
|
3
|
+
interface StubReporterSocket {
|
|
4
|
+
// Every complete line received so far, newline removed, in arrival order.
|
|
5
|
+
readonly lines: readonly string[];
|
|
6
|
+
|
|
7
|
+
// How many reads the stand-in has taken from its connections, so a test
|
|
8
|
+
// can wait until one piece of a split write has arrived.
|
|
9
|
+
readonly reads: number;
|
|
10
|
+
readonly waitForLine: (timeoutMs?: number) => Promise<string>;
|
|
11
|
+
readonly [Symbol.dispose]: () => void;
|
|
12
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A stand-in for the daemon's reporter socket: listens on the unix socket
|
|
17
|
+
* path and collects each newline-terminated line any connection writes,
|
|
18
|
+
* buffering a partial line per connection, as the daemon reads the lines
|
|
19
|
+
* hook and report commands send. It never writes back, and leaves each
|
|
20
|
+
* connection for the sender to close. `waitForLine` resolves with the first
|
|
21
|
+
* line once one has arrived, and rejects naming the socket when none arrives
|
|
22
|
+
* within `timeoutMs`, 5 seconds by default. Disposal stops the listener; hold the
|
|
23
|
+
* result with `using` or `await using`.
|
|
24
|
+
*/
|
|
25
|
+
export function startStubReporterSocket(path: string): StubReporterSocket {
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
let reads = 0;
|
|
28
|
+
|
|
29
|
+
const server = Bun.listen<{ pending: string }>({
|
|
30
|
+
unix: path,
|
|
31
|
+
socket: {
|
|
32
|
+
open(socket) {
|
|
33
|
+
socket.data = { pending: '' };
|
|
34
|
+
},
|
|
35
|
+
data(socket, chunk) {
|
|
36
|
+
reads += 1;
|
|
37
|
+
|
|
38
|
+
const parts = `${socket.data.pending}${chunk.toString()}`.split('\n');
|
|
39
|
+
|
|
40
|
+
socket.data.pending = parts.pop() ?? '';
|
|
41
|
+
|
|
42
|
+
lines.push(...parts);
|
|
43
|
+
},
|
|
44
|
+
error() {},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const stop = () => {
|
|
49
|
+
server.stop(true);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
lines,
|
|
54
|
+
get reads() {
|
|
55
|
+
return reads;
|
|
56
|
+
},
|
|
57
|
+
waitForLine: (timeoutMs = 5000) =>
|
|
58
|
+
waitFor(
|
|
59
|
+
() => {
|
|
60
|
+
const [first] = lines;
|
|
61
|
+
|
|
62
|
+
if (first === undefined) {
|
|
63
|
+
throw new Error(`no line has arrived at ${path}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return first;
|
|
67
|
+
},
|
|
68
|
+
{ timeoutMs },
|
|
69
|
+
),
|
|
70
|
+
[Symbol.dispose]: stop,
|
|
71
|
+
[Symbol.asyncDispose]: () => {
|
|
72
|
+
stop();
|
|
73
|
+
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
|
+
import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A daemon stand-in on a unix socket path that speaks the real wire format
|
|
6
|
+
* to a fleet restore check: it answers every `fleet.restore` with an empty
|
|
7
|
+
* result, answers each other request with the next reply the test pushes
|
|
8
|
+
* onto `lists`, and withholds the answer to any request that finds `lists`
|
|
9
|
+
* empty. `methods` holds the method of each request it took, in order.
|
|
10
|
+
* Disposal stops the listener; hold the result with `using` or
|
|
11
|
+
* `await using`.
|
|
12
|
+
*/
|
|
13
|
+
export function startStubRestoreDaemon(socketPath: string) {
|
|
14
|
+
const lists: Readonly<Record<string, unknown>>[] = [];
|
|
15
|
+
const methods: string[] = [];
|
|
16
|
+
|
|
17
|
+
// Each connection gets a decoder of its own, so a partial line from one
|
|
18
|
+
// client never joins a line from another.
|
|
19
|
+
const server = Bun.listen<{ lines: LineDecoder }>({
|
|
20
|
+
unix: socketPath,
|
|
21
|
+
socket: {
|
|
22
|
+
open(socket) {
|
|
23
|
+
socket.data = { lines: new LineDecoder() };
|
|
24
|
+
},
|
|
25
|
+
data(socket, buf) {
|
|
26
|
+
const requests = socket.data.lines
|
|
27
|
+
.splitChunk(buf)
|
|
28
|
+
.map((line) => decodeMessage(line))
|
|
29
|
+
.filter((decoded) => decoded.kind === 'request');
|
|
30
|
+
|
|
31
|
+
for (const request of requests) {
|
|
32
|
+
methods.push(request.msg.m);
|
|
33
|
+
|
|
34
|
+
const ok = request.msg.m === 'fleet.restore' ? {} : lists.shift();
|
|
35
|
+
|
|
36
|
+
if (ok !== undefined) {
|
|
37
|
+
socket.write(encodeMessage({ v: PROTOCOL_V, id: request.msg.id, ok }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const stop = () => {
|
|
45
|
+
server.stop(true);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
lists,
|
|
50
|
+
methods,
|
|
51
|
+
[Symbol.dispose]: stop,
|
|
52
|
+
[Symbol.asyncDispose]: () => {
|
|
53
|
+
stop();
|
|
54
|
+
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|