@zgeoff/atc 3.2.0 → 3.3.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/README.md +6 -0
- package/package.json +4 -3
- package/src/build-own-daemon-process.ts +16 -0
- package/src/build-restart-failure.ts +24 -0
- package/src/cli.ts +96 -0
- package/src/client/boot-daemon.ts +5 -32
- package/src/collect-restart-env.ts +18 -0
- package/src/collect-restart-plan.ts +157 -0
- package/src/daemon/daemon.ts +7 -3
- package/src/daemon-process.ts +12 -0
- package/src/find-restart-unit.ts +45 -0
- package/src/format-restart-preflight.ts +74 -0
- package/src/format-restart-report.ts +45 -0
- package/src/is-daemon-command-line.ts +33 -0
- package/src/is-daemon-process.ts +33 -0
- package/src/parse-daemon-flags.ts +33 -0
- package/src/parse-mismatch-build.ts +19 -0
- package/src/parse-restart-result.ts +65 -0
- package/src/parse-systemd-unit.ts +25 -0
- package/src/read-daemon-process.ts +50 -0
- package/src/read-fleet-snapshot.ts +37 -0
- package/src/read-restart-result.ts +17 -0
- package/src/read-stored-fleet-file.ts +74 -0
- package/src/read-stored-rows.ts +36 -0
- package/src/remove-restart-result.ts +11 -0
- package/src/restart-daemon.ts +149 -0
- package/src/restart-plan.ts +33 -0
- package/src/run-daemon-restart-worker.ts +77 -0
- package/src/run-daemon-restart.ts +228 -0
- package/src/run-systemctl.ts +33 -0
- package/src/shared/build-atc-command.ts +13 -0
- package/src/shared/config.ts +5 -0
- package/src/shared/find-pid-file-pid.ts +15 -0
- package/src/shared/get-build.ts +4 -3
- package/src/shared/is-process-alive.ts +21 -0
- package/src/shared/spawn-atc-detached.ts +34 -0
- package/src/start-replacement-daemon.ts +31 -0
- package/src/stop-daemon-process.ts +46 -0
- package/src/store/state-store.ts +2 -2
- package/src/test-utils/can-bind-addresses.ts +19 -0
- package/src/test-utils/create-migrated-state-db.ts +38 -0
- package/src/test-utils/env-originals.ts +6 -0
- package/src/test-utils/fixture-dir-provider.ts +131 -0
- package/src/test-utils/fixture-imp-port.ts +1692 -0
- package/src/test-utils/get-agent-entry.ts +17 -0
- package/src/test-utils/get-gateway-config.ts +18 -0
- package/src/test-utils/isolate-home.ts +60 -0
- package/src/test-utils/read-json-record.ts +20 -0
- package/src/test-utils/remove-env-overrides.ts +18 -0
- package/src/test-utils/run-legacy-daemon.ts +38 -0
- package/src/test-utils/run-listener-daemon.ts +51 -0
- package/src/test-utils/run-mcp-authorization.ts +118 -0
- package/src/test-utils/run-source-daemon.ts +110 -0
- package/src/test-utils/setup-fake-systemd.ts +125 -0
- package/src/test-utils/setup-mcp-http.ts +126 -0
- package/src/test-utils/setup-temp-dir.ts +33 -0
- package/src/test-utils/spawn-named-session.ts +27 -0
- package/src/test-utils/start-cut-proxy.ts +175 -0
- package/src/test-utils/start-git-http-server.ts +102 -0
- package/src/test-utils/start-legacy-daemon.ts +111 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
- package/src/test-utils/update-env.ts +19 -0
- package/src/test-utils/wait-for.ts +38 -0
- package/src/verify-restored-fleet.ts +155 -0
- package/src/wait-for-replacement.ts +112 -0
- package/src/write-restart-result.ts +17 -0
- /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
- /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
4
|
+
import { DaemonClient } from '../client/daemon-client';
|
|
5
|
+
import { startDaemon } from '../daemon/daemon';
|
|
6
|
+
import type { DaemonHandle } from '../daemon/daemon';
|
|
7
|
+
import { openMCPAuth } from '../mcp/open-mcp-auth';
|
|
8
|
+
import { ReconnectingCaller } from '../mcp/reconnecting-caller';
|
|
9
|
+
import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
|
|
10
|
+
import { setupTempDir } from './setup-temp-dir';
|
|
11
|
+
|
|
12
|
+
const SLEEP_ADAPTER: AgentAdapter = {
|
|
13
|
+
id: 'claude',
|
|
14
|
+
headlessRunner: null,
|
|
15
|
+
screenDetector: null,
|
|
16
|
+
takesMessages: false,
|
|
17
|
+
planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
|
|
18
|
+
normalizeHook: () => ({ kind: 'heartbeat' }),
|
|
19
|
+
loadName: () => Promise.resolve(null),
|
|
20
|
+
canResume: () => true,
|
|
21
|
+
buildResumeCommand: () => null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface MCPHTTPSetupOptions {
|
|
25
|
+
// How long a rotated refresh token still answers with its successor.
|
|
26
|
+
readonly refreshReuseSeconds?: number;
|
|
27
|
+
|
|
28
|
+
// Further Host header values the server accepts.
|
|
29
|
+
readonly allowedHosts?: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A real daemon in a temp directory and `atc mcp --http` in front of it on a
|
|
34
|
+
* free port, with every approval line and request line the server prints
|
|
35
|
+
* collected. The authorization server's database sits where atc keeps it
|
|
36
|
+
* under `home`, so a CLI run with that home opens the same file. `store` is
|
|
37
|
+
* the authorization server's database opened a second time the way
|
|
38
|
+
* `atc clients` and `atc grants` open it, and `addClient` adds a client
|
|
39
|
+
* through it and returns the client id. `restartDaemon` stops the daemon and
|
|
40
|
+
* starts a fresh one on the same socket and database, the way an operator
|
|
41
|
+
* restarts it, with the principals it is given; `countDaemonClients` reads how many connections the current
|
|
42
|
+
* daemon holds open. Hold the result with `await using`.
|
|
43
|
+
*/
|
|
44
|
+
export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
45
|
+
const tmp = setupTempDir('atc-mcp-http-');
|
|
46
|
+
const socketPath = join(tmp.dir, 'daemon.sock');
|
|
47
|
+
const stateDir = join(tmp.dir, '.local', 'state', 'atc');
|
|
48
|
+
|
|
49
|
+
mkdirSync(stateDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const dbPath = join(stateDir, 'mcp-auth.db');
|
|
52
|
+
const approvals: string[] = [];
|
|
53
|
+
const requests: string[] = [];
|
|
54
|
+
|
|
55
|
+
const startTestDaemon = (principals: ReadonlyMap<string, readonly string[]> | null = null) =>
|
|
56
|
+
startDaemon({
|
|
57
|
+
socketPath,
|
|
58
|
+
reporterSocketPath: join(tmp.dir, 'reporter.sock'),
|
|
59
|
+
build: 'atc/test-build',
|
|
60
|
+
adapter: SLEEP_ADAPTER,
|
|
61
|
+
dbPath: join(tmp.dir, 'state.db'),
|
|
62
|
+
statusPath: join(tmp.dir, 'status.json'),
|
|
63
|
+
principals,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let daemon: DaemonHandle = await startTestDaemon();
|
|
67
|
+
|
|
68
|
+
const caller = new ReconnectingCaller(socketPath, 'atc/test-build', (path) =>
|
|
69
|
+
DaemonClient.open(path),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const server = await startMCPHTTPServer({
|
|
73
|
+
caller,
|
|
74
|
+
build: 'atc/test-build',
|
|
75
|
+
host: '127.0.0.1',
|
|
76
|
+
port: 0,
|
|
77
|
+
publicURL: null,
|
|
78
|
+
allowedHosts: options.allowedHosts ?? [],
|
|
79
|
+
dbPath,
|
|
80
|
+
printApproval: (line) => {
|
|
81
|
+
approvals.push(line);
|
|
82
|
+
},
|
|
83
|
+
printRequest: (line) => {
|
|
84
|
+
requests.push(line);
|
|
85
|
+
},
|
|
86
|
+
...(options.refreshReuseSeconds === undefined
|
|
87
|
+
? {}
|
|
88
|
+
: { refreshReuseSeconds: options.refreshReuseSeconds }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const store = await openMCPAuth({ dbPath, origin: null });
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
home: tmp.dir,
|
|
95
|
+
url: server.url,
|
|
96
|
+
origin: server.origin,
|
|
97
|
+
dbPath,
|
|
98
|
+
approvals,
|
|
99
|
+
requests,
|
|
100
|
+
caller,
|
|
101
|
+
store,
|
|
102
|
+
async addClient(name: string, redirectURIs: readonly string[]): Promise<string> {
|
|
103
|
+
const created = await store.auth.api.createFixedClient({
|
|
104
|
+
body: { name, redirectURIs: [...redirectURIs] },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return created.clientID;
|
|
108
|
+
},
|
|
109
|
+
countDaemonClients() {
|
|
110
|
+
return daemon.countClients();
|
|
111
|
+
},
|
|
112
|
+
async restartDaemon(principals: ReadonlyMap<string, readonly string[]> | null = null) {
|
|
113
|
+
await daemon.stop();
|
|
114
|
+
|
|
115
|
+
daemon = await startTestDaemon(principals);
|
|
116
|
+
},
|
|
117
|
+
async [Symbol.asyncDispose]() {
|
|
118
|
+
await server.stop();
|
|
119
|
+
await store.close();
|
|
120
|
+
await caller.stop();
|
|
121
|
+
await daemon.stop();
|
|
122
|
+
|
|
123
|
+
tmp[Symbol.dispose]();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
interface TempDir {
|
|
6
|
+
readonly dir: string;
|
|
7
|
+
readonly [Symbol.dispose]: () => void;
|
|
8
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a disposable directory under the system temp root, named by the
|
|
13
|
+
* prefix. Disposal removes the tree; hold the result with `using` in a
|
|
14
|
+
* synchronous test and `await using` in an asynchronous one, so the
|
|
15
|
+
* directory outlives exactly the test that made it.
|
|
16
|
+
*/
|
|
17
|
+
export function setupTempDir(prefix: string): TempDir {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
19
|
+
|
|
20
|
+
const remove = () => {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
dir,
|
|
26
|
+
[Symbol.dispose]: remove,
|
|
27
|
+
[Symbol.asyncDispose]: () => {
|
|
28
|
+
remove();
|
|
29
|
+
|
|
30
|
+
return Promise.resolve();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
type SendRequest = (
|
|
4
|
+
m: string,
|
|
5
|
+
p?: Readonly<Record<string, unknown>>,
|
|
6
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sends one session.spawn request at 80x24 through the given request sender
|
|
10
|
+
* and returns the new session's id. Throws when the answer carries no
|
|
11
|
+
* session descriptor with a string id.
|
|
12
|
+
*/
|
|
13
|
+
export async function spawnNamedSession(
|
|
14
|
+
send: SendRequest,
|
|
15
|
+
name: string,
|
|
16
|
+
cwd: string,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const ok = await send('session.spawn', { cwd, name, cols: 80, rows: 24 });
|
|
19
|
+
|
|
20
|
+
const spawned = ok['session'];
|
|
21
|
+
|
|
22
|
+
if (!isRecord(spawned) || typeof spawned['id'] !== 'string') {
|
|
23
|
+
throw new Error('no session in spawn answer');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return spawned['id'];
|
|
27
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Socket } from 'bun';
|
|
2
|
+
|
|
3
|
+
interface CutProxyOptions {
|
|
4
|
+
// The address the proxy forwards to.
|
|
5
|
+
readonly target: { readonly hostname: string; readonly port: number };
|
|
6
|
+
|
|
7
|
+
// The request method whose response the proxy loses, and how many times.
|
|
8
|
+
readonly method: string;
|
|
9
|
+
readonly cuts: number;
|
|
10
|
+
|
|
11
|
+
// `close` ends both sides where the response would pass; `hold`
|
|
12
|
+
// swallows it and every later byte from the target, leaving the
|
|
13
|
+
// connection open; `drop` ends both sides in place of forwarding the
|
|
14
|
+
// request, so the target never sees it.
|
|
15
|
+
readonly mode: 'close' | 'hold' | 'drop';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CutProxy {
|
|
19
|
+
readonly port: number;
|
|
20
|
+
|
|
21
|
+
// How many requests of the method the proxy has seen.
|
|
22
|
+
readonly countRequests: () => number;
|
|
23
|
+
readonly stop: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One proxied connection: the target side once it is connected, what the
|
|
27
|
+
// client sent before it was, the client's unfinished line, and whether
|
|
28
|
+
// the target's next answer is lost or every answer from now on is.
|
|
29
|
+
interface ProxyLink {
|
|
30
|
+
upstream: Socket | null;
|
|
31
|
+
readonly pending: Uint8Array[];
|
|
32
|
+
readonly decoder: TextDecoder;
|
|
33
|
+
lineBuffer: string;
|
|
34
|
+
cutting: boolean;
|
|
35
|
+
held: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A TCP proxy on a loopback port that forwards an NDJSON protocol
|
|
40
|
+
* connection byte for byte, except that for the first `cuts` requests of
|
|
41
|
+
* the method it loses the target's next answer after the request reached
|
|
42
|
+
* the target: the request runs, and the client never sees its response.
|
|
43
|
+
*/
|
|
44
|
+
export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
45
|
+
let cutsLeft = options.cuts;
|
|
46
|
+
let requests = 0;
|
|
47
|
+
|
|
48
|
+
const links = new Set<Socket<ProxyLink>>();
|
|
49
|
+
|
|
50
|
+
const server = Bun.listen<ProxyLink>({
|
|
51
|
+
hostname: '127.0.0.1',
|
|
52
|
+
port: 0,
|
|
53
|
+
socket: {
|
|
54
|
+
open(client) {
|
|
55
|
+
client.data = {
|
|
56
|
+
upstream: null,
|
|
57
|
+
pending: [],
|
|
58
|
+
decoder: new TextDecoder(),
|
|
59
|
+
lineBuffer: '',
|
|
60
|
+
cutting: false,
|
|
61
|
+
held: false,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
links.add(client);
|
|
65
|
+
void openUpstream(client, options);
|
|
66
|
+
},
|
|
67
|
+
data(client, buf) {
|
|
68
|
+
client.data.lineBuffer += client.data.decoder.decode(buf, { stream: true });
|
|
69
|
+
|
|
70
|
+
const lines = client.data.lineBuffer.split('\n');
|
|
71
|
+
|
|
72
|
+
client.data.lineBuffer = lines.pop() ?? '';
|
|
73
|
+
|
|
74
|
+
let dropped = false;
|
|
75
|
+
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
if (!line.includes(`"m":"${options.method}"`)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
requests++;
|
|
82
|
+
|
|
83
|
+
if (cutsLeft > 0) {
|
|
84
|
+
cutsLeft--;
|
|
85
|
+
client.data.cutting = options.mode !== 'drop';
|
|
86
|
+
dropped = options.mode === 'drop';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A dropped request ends the link with nothing of its chunk
|
|
91
|
+
// forwarded.
|
|
92
|
+
if (dropped) {
|
|
93
|
+
client.end();
|
|
94
|
+
client.data.upstream?.end();
|
|
95
|
+
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (client.data.upstream === null) {
|
|
100
|
+
client.data.pending.push(new Uint8Array(buf));
|
|
101
|
+
} else {
|
|
102
|
+
client.data.upstream.write(buf);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
close(client) {
|
|
106
|
+
links.delete(client);
|
|
107
|
+
client.data.upstream?.end();
|
|
108
|
+
},
|
|
109
|
+
error() {},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
port: server.port,
|
|
115
|
+
countRequests: () => requests,
|
|
116
|
+
stop() {
|
|
117
|
+
for (const client of links) {
|
|
118
|
+
client.end();
|
|
119
|
+
client.data.upstream?.end();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
server.stop(true);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Connects the client's link to the target, forwarding what the client
|
|
128
|
+
// sent meanwhile, and ends the client when the target cannot be reached.
|
|
129
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket is a live handle the proxy writes to
|
|
130
|
+
async function openUpstream(client: Socket<ProxyLink>, options: CutProxyOptions): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
const upstream = await Bun.connect({
|
|
133
|
+
hostname: options.target.hostname,
|
|
134
|
+
port: options.target.port,
|
|
135
|
+
socket: {
|
|
136
|
+
data(_upstream, buf) {
|
|
137
|
+
if (client.data.held) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!client.data.cutting) {
|
|
142
|
+
client.write(buf);
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
client.data.cutting = false;
|
|
148
|
+
|
|
149
|
+
if (options.mode === 'hold') {
|
|
150
|
+
client.data.held = true;
|
|
151
|
+
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
client.end();
|
|
156
|
+
client.data.upstream?.end();
|
|
157
|
+
},
|
|
158
|
+
close() {
|
|
159
|
+
if (!client.data.held) {
|
|
160
|
+
client.end();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
error() {},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
client.data.upstream = upstream;
|
|
168
|
+
|
|
169
|
+
for (const chunk of client.data.pending.splice(0)) {
|
|
170
|
+
upstream.write(chunk);
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
client.end();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
interface GitHTTPServer {
|
|
2
|
+
// The base URL a repository under the root is served at, ending in `/`.
|
|
3
|
+
readonly url: string;
|
|
4
|
+
|
|
5
|
+
// Every Authorization header a request carried, in arrival order.
|
|
6
|
+
readonly authorizations: string[];
|
|
7
|
+
readonly stop: () => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Serves the bare repositories under a directory over smart HTTP through
|
|
12
|
+
* `git http-backend` on a loopback port, and refuses every request that
|
|
13
|
+
* carries no basic auth, so a client only clones by authenticating. The
|
|
14
|
+
* backend runs with the environment it is given. With a delay, each
|
|
15
|
+
* authenticated request waits that long before it is served, which makes a
|
|
16
|
+
* clone through it slow.
|
|
17
|
+
*/
|
|
18
|
+
export function startGitHTTPServer(
|
|
19
|
+
root: string,
|
|
20
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
21
|
+
options: { readonly delayMs?: number } = {},
|
|
22
|
+
): GitHTTPServer {
|
|
23
|
+
const authorizations: string[] = [];
|
|
24
|
+
|
|
25
|
+
const server = Bun.serve({
|
|
26
|
+
port: 0,
|
|
27
|
+
hostname: '127.0.0.1',
|
|
28
|
+
async fetch(request) {
|
|
29
|
+
const authorization = request.headers.get('authorization');
|
|
30
|
+
|
|
31
|
+
if (authorization === null) {
|
|
32
|
+
return new Response('auth required', {
|
|
33
|
+
status: 401,
|
|
34
|
+
headers: { 'www-authenticate': 'Basic realm="atc"' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
authorizations.push(authorization);
|
|
39
|
+
|
|
40
|
+
if (options.delayMs !== undefined) {
|
|
41
|
+
await Bun.sleep(options.delayMs);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = new URL(request.url);
|
|
45
|
+
|
|
46
|
+
const body = await request.arrayBuffer();
|
|
47
|
+
|
|
48
|
+
const backend = Bun.spawn(['git', 'http-backend'], {
|
|
49
|
+
env: {
|
|
50
|
+
...env,
|
|
51
|
+
GIT_PROJECT_ROOT: root,
|
|
52
|
+
GIT_HTTP_EXPORT_ALL: '1',
|
|
53
|
+
REQUEST_METHOD: request.method,
|
|
54
|
+
PATH_INFO: url.pathname,
|
|
55
|
+
QUERY_STRING: url.search.slice(1),
|
|
56
|
+
CONTENT_TYPE: request.headers.get('content-type') ?? '',
|
|
57
|
+
HTTP_CONTENT_ENCODING: request.headers.get('content-encoding') ?? '',
|
|
58
|
+
HTTP_GIT_PROTOCOL: request.headers.get('git-protocol') ?? '',
|
|
59
|
+
REMOTE_USER: 'atc',
|
|
60
|
+
REMOTE_ADDR: '127.0.0.1',
|
|
61
|
+
},
|
|
62
|
+
stdin: new Uint8Array(body),
|
|
63
|
+
stdout: 'pipe',
|
|
64
|
+
stderr: 'ignore',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const raw = await new Response(backend.stdout).arrayBuffer();
|
|
68
|
+
|
|
69
|
+
return toCGIResponse(Buffer.from(raw));
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
url: `http://127.0.0.1:${server.port}/`,
|
|
75
|
+
authorizations,
|
|
76
|
+
stop: () => server.stop(true),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A CGI answer is a header block, a blank line, and the body; a `Status`
|
|
81
|
+
// header sets the response status.
|
|
82
|
+
function toCGIResponse(output: Buffer): Response {
|
|
83
|
+
const split = output.indexOf('\r\n\r\n');
|
|
84
|
+
|
|
85
|
+
const headers = new Headers();
|
|
86
|
+
|
|
87
|
+
let status = 200;
|
|
88
|
+
|
|
89
|
+
for (const line of output.subarray(0, split).toString('latin1').split('\r\n')) {
|
|
90
|
+
const colon = line.indexOf(':');
|
|
91
|
+
const name = line.slice(0, colon).trim();
|
|
92
|
+
const value = line.slice(colon + 1).trim();
|
|
93
|
+
|
|
94
|
+
if (name.toLowerCase() === 'status') {
|
|
95
|
+
status = Number(value.split(' ')[0]);
|
|
96
|
+
} else {
|
|
97
|
+
headers.append(name, value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return new Response(output.subarray(split + 4), { status, headers });
|
|
102
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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 ReceivedRequest {
|
|
11
|
+
readonly m: string;
|
|
12
|
+
readonly p: Readonly<Record<string, unknown>> | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface LegacyDaemonOptions {
|
|
16
|
+
readonly features?: readonly DaemonFeature[];
|
|
17
|
+
|
|
18
|
+
// The protocol version the daemon speaks; a hello on any other version is
|
|
19
|
+
// refused with `protocol_mismatch`, as a real daemon refuses it.
|
|
20
|
+
readonly protocol?: number;
|
|
21
|
+
|
|
22
|
+
readonly replies?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An older atc daemon listening on a unix socket and speaking the current
|
|
27
|
+
* protocol version, or the one `protocol` holds. Its `daemon.hello` announces `features` when the options
|
|
28
|
+
* hold that list, and announces none without it, as a daemon from before
|
|
29
|
+
* features did. It answers `daemon.hello`, `daemon.ping`, and each method in
|
|
30
|
+
* `replies` with that method's reply, and refuses every other method with
|
|
31
|
+
* `unknown_method`. Every request it receives is recorded in `requests`. Stop
|
|
32
|
+
* it with `stop`.
|
|
33
|
+
*/
|
|
34
|
+
export function startLegacyDaemon(socketPath: string, options: LegacyDaemonOptions = {}) {
|
|
35
|
+
const answers: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
|
36
|
+
'daemon.hello': {
|
|
37
|
+
daemon: 'atc/legacy-build',
|
|
38
|
+
limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
|
|
39
|
+
lastUsedAgent: 'claude',
|
|
40
|
+
...(options.features === undefined ? {} : { features: options.features }),
|
|
41
|
+
},
|
|
42
|
+
'daemon.ping': {},
|
|
43
|
+
...options.replies,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const protocol = options.protocol ?? PROTOCOL_V;
|
|
47
|
+
const requests: ReceivedRequest[] = [];
|
|
48
|
+
|
|
49
|
+
const server = Bun.listen<{ buffer: string }>({
|
|
50
|
+
unix: socketPath,
|
|
51
|
+
socket: {
|
|
52
|
+
open(socket) {
|
|
53
|
+
socket.data = { buffer: '' };
|
|
54
|
+
},
|
|
55
|
+
data(socket, chunk) {
|
|
56
|
+
const lines = `${socket.data.buffer}${chunk.toString()}`.split('\n');
|
|
57
|
+
|
|
58
|
+
socket.data.buffer = lines.pop() ?? '';
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const decoded = decodeMessage(line);
|
|
62
|
+
|
|
63
|
+
if (decoded.kind !== 'request') {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const req = decoded.msg;
|
|
68
|
+
|
|
69
|
+
requests.push({ m: req.m, p: req.p });
|
|
70
|
+
|
|
71
|
+
if (req.m === 'daemon.hello' && req.v !== protocol) {
|
|
72
|
+
const client = req.p?.['client'];
|
|
73
|
+
|
|
74
|
+
socket.write(
|
|
75
|
+
encodeMessage({
|
|
76
|
+
v: protocol,
|
|
77
|
+
id: req.id,
|
|
78
|
+
err: {
|
|
79
|
+
code: 'protocol_mismatch',
|
|
80
|
+
msg: `${String(client)} speaks protocol v${req.v}, daemon atc/legacy-build speaks v${protocol}; restart the daemon so both run the same build`,
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const answer = answers[req.m];
|
|
89
|
+
|
|
90
|
+
const reply =
|
|
91
|
+
answer === undefined
|
|
92
|
+
? {
|
|
93
|
+
v: protocol,
|
|
94
|
+
id: req.id,
|
|
95
|
+
err: { code: 'unknown_method' as const, msg: `unknown method '${req.m}'` },
|
|
96
|
+
}
|
|
97
|
+
: { v: protocol, id: req.id, ok: answer };
|
|
98
|
+
|
|
99
|
+
socket.write(encodeMessage(reply));
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
requests,
|
|
107
|
+
stop() {
|
|
108
|
+
server.stop(true);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { waitFor } from './wait-for';
|
|
2
|
+
|
|
3
|
+
interface SocketLines {
|
|
4
|
+
readonly lines: string[];
|
|
5
|
+
readonly write: (data: string) => void;
|
|
6
|
+
readonly waitForLine: (count?: number, timeoutMs?: number) => Promise<string[]>;
|
|
7
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Connects to a unix socket and collects every complete newline-terminated
|
|
12
|
+
* line it sends, buffering a partial line across reads. `waitForLine` polls
|
|
13
|
+
* until the collected count reaches `count` and returns the lines, throwing
|
|
14
|
+
* when `timeoutMs` passes first; disposal ends the connection.
|
|
15
|
+
*/
|
|
16
|
+
export async function subscribeToSocketLines(path: string): Promise<SocketLines> {
|
|
17
|
+
const lines: string[] = [];
|
|
18
|
+
let buffer = '';
|
|
19
|
+
|
|
20
|
+
const socket = await Bun.connect({
|
|
21
|
+
unix: path,
|
|
22
|
+
socket: {
|
|
23
|
+
data(_s, buf) {
|
|
24
|
+
buffer += buf.toString();
|
|
25
|
+
|
|
26
|
+
const parts = buffer.split('\n');
|
|
27
|
+
|
|
28
|
+
buffer = parts.pop() ?? '';
|
|
29
|
+
|
|
30
|
+
lines.push(...parts.filter((part) => part.trim() !== ''));
|
|
31
|
+
},
|
|
32
|
+
close() {},
|
|
33
|
+
error() {},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
lines,
|
|
39
|
+
write(data: string) {
|
|
40
|
+
socket.write(data);
|
|
41
|
+
},
|
|
42
|
+
waitForLine(count = 1, timeoutMs = 5000) {
|
|
43
|
+
return waitFor(
|
|
44
|
+
() => {
|
|
45
|
+
if (lines.length < count) {
|
|
46
|
+
throw new Error(`timed out waiting for ${count} lines; got ${JSON.stringify(lines)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return lines;
|
|
50
|
+
},
|
|
51
|
+
{ timeoutMs },
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
[Symbol.asyncDispose]: () => {
|
|
55
|
+
socket.end();
|
|
56
|
+
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { envOriginals } from './env-originals';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overrides an environment variable for the rest of the running test, or
|
|
5
|
+
* unsets it when the value is `undefined`. The value it held before the
|
|
6
|
+
* test's first override of it is recorded once, and the preload puts it back
|
|
7
|
+
* after the test.
|
|
8
|
+
*/
|
|
9
|
+
export function updateEnv(key: string, value: string | undefined): void {
|
|
10
|
+
if (!envOriginals.has(key)) {
|
|
11
|
+
envOriginals.set(key, process.env[key]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (value === undefined) {
|
|
15
|
+
delete process.env[key];
|
|
16
|
+
} else {
|
|
17
|
+
process.env[key] = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
interface WaitForOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Milliseconds between retries.
|
|
4
|
+
*/
|
|
5
|
+
readonly intervalMs?: number;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Milliseconds before the wait gives up.
|
|
9
|
+
*/
|
|
10
|
+
readonly timeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Retries the attempt until it stops throwing or rejecting, resolving with
|
|
15
|
+
* its value — so assertion-style checks read exactly as they would in a
|
|
16
|
+
* straight-line test body. Once the deadline passes, the attempt's final
|
|
17
|
+
* failure is rethrown as the wait's rejection.
|
|
18
|
+
*/
|
|
19
|
+
export async function waitFor<T>(
|
|
20
|
+
attempt: () => Promise<T> | T,
|
|
21
|
+
options: Readonly<WaitForOptions> = {},
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
const intervalMs = options.intervalMs ?? 20;
|
|
24
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
25
|
+
const deadline = Date.now() + timeoutMs;
|
|
26
|
+
|
|
27
|
+
for (;;) {
|
|
28
|
+
try {
|
|
29
|
+
return await attempt();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (Date.now() >= deadline) {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await Bun.sleep(intervalMs);
|
|
37
|
+
}
|
|
38
|
+
}
|