@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,17 @@
|
|
|
1
|
+
import type { AgentEntry } from '../shared/collect-agents';
|
|
2
|
+
import type { Config } from '../shared/config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The registry entry a parsed config holds under an agent id. A test that
|
|
6
|
+
* builds an adapter from a config reads the entry through this, so a config
|
|
7
|
+
* that lacks the agent fails the test with the id named.
|
|
8
|
+
*/
|
|
9
|
+
export function getAgentEntry(config: Pick<Config, 'agents'>, id: string): AgentEntry {
|
|
10
|
+
const entry = config.agents.find((candidate) => candidate.id === id);
|
|
11
|
+
|
|
12
|
+
if (entry === undefined) {
|
|
13
|
+
throw new Error(`the config holds no agent '${id}'`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return entry;
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GatewayConfig } from '../shared/collect-gateways';
|
|
2
|
+
import type { Config } from '../shared/config';
|
|
3
|
+
import { getAgentEntry } from './get-agent-entry';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The gateway a parsed config holds under an agent id: a Claude entry with a
|
|
7
|
+
* base URL. Throws when the config holds no such agent or the entry has no
|
|
8
|
+
* base URL.
|
|
9
|
+
*/
|
|
10
|
+
export function getGatewayConfig(config: Pick<Config, 'agents'>, id: string): GatewayConfig {
|
|
11
|
+
const entry = getAgentEntry(config, id);
|
|
12
|
+
|
|
13
|
+
if (entry.baseURL === undefined) {
|
|
14
|
+
throw new Error(`the agent '${id}' has no baseURL`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { ...entry, baseURL: entry.baseURL };
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach } from 'bun:test';
|
|
2
|
+
import { join, normalize } from 'node:path';
|
|
3
|
+
import { removeEnvOverrides } from './remove-env-overrides';
|
|
4
|
+
|
|
5
|
+
// Holds every test run to the isolated home the package test scripts set
|
|
6
|
+
// up before Bun starts, marked by ATC_TEST_HOME. A bare `bun test` stops
|
|
7
|
+
// here: Bun hands a spawned child the environment it started with, not one
|
|
8
|
+
// a preload changed, so only a home set before Bun starts reaches every
|
|
9
|
+
// subprocess a test runs. A run under a marker whose paths do not match it
|
|
10
|
+
// stops too. Both exit before any test imports atc. Once the home holds,
|
|
11
|
+
// every environment variable a test overrides through the shared helper is
|
|
12
|
+
// put back after that test, so the test home's own values return too.
|
|
13
|
+
const marker = process.env['ATC_TEST_HOME'];
|
|
14
|
+
|
|
15
|
+
if (marker === undefined) {
|
|
16
|
+
stopRun('a bare `bun test` runs against your real home; run `bun run test` instead');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assertTestHome(marker);
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
removeEnvOverrides();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// An inherited marker is trusted only when every isolated variable already
|
|
26
|
+
// points under it and no enclosing session's variables remain; a stale or
|
|
27
|
+
// hand-set marker stops the run before any test imports atc. An inherited
|
|
28
|
+
// CLAUDE_CONFIG_DIR would move the Claude config folder out of the home.
|
|
29
|
+
function assertTestHome(root: string): void {
|
|
30
|
+
const expected: Readonly<Record<string, string>> = {
|
|
31
|
+
HOME: join(root, 'home'),
|
|
32
|
+
XDG_RUNTIME_DIR: join(root, 'runtime'),
|
|
33
|
+
XDG_CONFIG_HOME: join(root, 'home', '.config'),
|
|
34
|
+
XDG_DATA_HOME: join(root, 'home', '.local', 'share'),
|
|
35
|
+
XDG_STATE_HOME: join(root, 'home', '.local', 'state'),
|
|
36
|
+
XDG_CACHE_HOME: join(root, 'home', '.cache'),
|
|
37
|
+
GROK_HOME: join(root, 'home', '.grok'),
|
|
38
|
+
CODEX_HOME: join(root, 'home', '.codex'),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const mismatched = Object.entries(expected)
|
|
42
|
+
.filter(([name, path]) => normalize(process.env[name] ?? '') !== path)
|
|
43
|
+
.map(([name]) => name);
|
|
44
|
+
|
|
45
|
+
const leftover = ['ATC_SESSION_ID', 'ATC_SOCKET', 'CLAUDE_CONFIG_DIR'].filter(
|
|
46
|
+
(name) => name in process.env,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (mismatched.length > 0 || leftover.length > 0) {
|
|
50
|
+
stopRun(
|
|
51
|
+
`ATC_TEST_HOME is set to ${root} but ${[...mismatched, ...leftover].join(', ')} ` +
|
|
52
|
+
'does not match the test home it marks; unset ATC_TEST_HOME and run `bun run test`',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stopRun(message: string): never {
|
|
58
|
+
console.error(`test home: ${message}`);
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
interface JSONBody {
|
|
4
|
+
readonly json: () => Promise<unknown>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads a response body as a JSON object, for tests asserting on an HTTP
|
|
9
|
+
* endpoint's payload. Throws when the body is any other JSON value, so a
|
|
10
|
+
* malformed response fails the test instead of passing it vacuously.
|
|
11
|
+
*/
|
|
12
|
+
export async function readJSONRecord(response: JSONBody): Promise<Record<string, unknown>> {
|
|
13
|
+
const body: unknown = await response.json();
|
|
14
|
+
|
|
15
|
+
if (!isRecord(body) || Array.isArray(body)) {
|
|
16
|
+
throw new TypeError('response body is not a JSON object');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { envOriginals } from './env-originals';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Puts every overridden environment variable back to the value it held before
|
|
5
|
+
* its first override, unsetting one that was unset, and forgets the recorded
|
|
6
|
+
* values.
|
|
7
|
+
*/
|
|
8
|
+
export function removeEnvOverrides(): void {
|
|
9
|
+
for (const [key, value] of envOriginals) {
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
delete process.env[key];
|
|
12
|
+
} else {
|
|
13
|
+
process.env[key] = value;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
envOriginals.clear();
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
4
|
+
import { startLegacyDaemon } from './start-legacy-daemon';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs a daemon of another protocol version as its own process, recorded in
|
|
8
|
+
* the state directory the way a real daemon records itself, so a client
|
|
9
|
+
* that finds it by its record can stop it. Arguments: the socket path to
|
|
10
|
+
* listen on and the state directory. It refuses every handshake with
|
|
11
|
+
* `protocol_mismatch`, prints `up` once it listens, and runs until it is
|
|
12
|
+
* killed.
|
|
13
|
+
*/
|
|
14
|
+
function main() {
|
|
15
|
+
const socketPath = process.argv.at(2);
|
|
16
|
+
const stateDir = process.argv.at(3);
|
|
17
|
+
|
|
18
|
+
if (socketPath === undefined || stateDir === undefined) {
|
|
19
|
+
throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
startLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1 });
|
|
23
|
+
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(stateDir, 'daemon.json'),
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
socketPath,
|
|
29
|
+
reporterSocketPath: `${socketPath}.reporter`,
|
|
30
|
+
eventsSocketPath: null,
|
|
31
|
+
listenPort: null,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
console.log('up');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { startDaemon } from '../daemon/daemon';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs a daemon with a TCP listener on a kernel-chosen loopback port, its
|
|
6
|
+
* state under `ATC_TEST_DIR` and its token file at `gateway-token` there.
|
|
7
|
+
* The listener logs through its own stderr writer, delays no handshake, and
|
|
8
|
+
* logs every refusal on a line of its own. It prints the port it bound once
|
|
9
|
+
* it listens, and SIGTERM stops it.
|
|
10
|
+
*/
|
|
11
|
+
async function main() {
|
|
12
|
+
const dir = process.env['ATC_TEST_DIR'] ?? '';
|
|
13
|
+
|
|
14
|
+
const handle = await startDaemon({
|
|
15
|
+
socketPath: join(dir, 'daemon.sock'),
|
|
16
|
+
reporterSocketPath: join(dir, 'reporter.sock'),
|
|
17
|
+
build: 'atc/test-build',
|
|
18
|
+
adapter: {
|
|
19
|
+
id: 'claude',
|
|
20
|
+
screenDetector: null,
|
|
21
|
+
takesMessages: true,
|
|
22
|
+
headlessRunner: null,
|
|
23
|
+
planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
|
|
24
|
+
normalizeHook: () => ({ kind: 'prompt-submitted' }),
|
|
25
|
+
loadName: () => Promise.resolve(null),
|
|
26
|
+
canResume: () => true,
|
|
27
|
+
buildResumeCommand: () => 'claude --resume',
|
|
28
|
+
},
|
|
29
|
+
dbPath: join(dir, 'state.db'),
|
|
30
|
+
statusPath: join(dir, 'status.json'),
|
|
31
|
+
listen: {
|
|
32
|
+
host: '127.0.0.1',
|
|
33
|
+
port: 0,
|
|
34
|
+
tokenFile: join(dir, 'gateway-token'),
|
|
35
|
+
failureDelayMs: 0,
|
|
36
|
+
refusalLogIntervalMs: 0,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
process.stdout.write(`${String(handle.listenPort)}\n`);
|
|
41
|
+
|
|
42
|
+
process.on('SIGTERM', () => {
|
|
43
|
+
void (async () => {
|
|
44
|
+
await handle.stop();
|
|
45
|
+
|
|
46
|
+
process.exit(0);
|
|
47
|
+
})();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await main();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
interface MCPAuthorizationServer {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly origin: string;
|
|
6
|
+
readonly approvals: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface MCPAuthorizationRequest {
|
|
10
|
+
readonly clientID: string;
|
|
11
|
+
readonly redirectURI: string;
|
|
12
|
+
|
|
13
|
+
// The scope the client requests.
|
|
14
|
+
readonly scope: string;
|
|
15
|
+
|
|
16
|
+
// The scopes the operator leaves ticked on the consent page.
|
|
17
|
+
readonly ticked: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MCPAuthorization {
|
|
21
|
+
// Where the consent page sent the browser back to.
|
|
22
|
+
readonly callback: URL;
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly verifier: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drives one authorization through a running MCP HTTP server the way a
|
|
29
|
+
* browser and the operator do: opens the authorization request with PKCE,
|
|
30
|
+
* types the approval code the server printed into the login page, and ticks
|
|
31
|
+
* the given scopes on the consent page. Each request starts with no cookies,
|
|
32
|
+
* as a fresh browser would. Returns where the browser landed and what a token
|
|
33
|
+
* request needs, and throws when any step's response lacks the value the next
|
|
34
|
+
* step depends on.
|
|
35
|
+
*/
|
|
36
|
+
export async function runMCPAuthorization(
|
|
37
|
+
server: MCPAuthorizationServer,
|
|
38
|
+
request: MCPAuthorizationRequest,
|
|
39
|
+
): Promise<MCPAuthorization> {
|
|
40
|
+
const verifier = `test-verifier-${crypto.randomUUID()}`;
|
|
41
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
42
|
+
|
|
43
|
+
const authorize = new URL(`${server.url}/oauth2/authorize`);
|
|
44
|
+
|
|
45
|
+
authorize.search = new URLSearchParams({
|
|
46
|
+
response_type: 'code',
|
|
47
|
+
client_id: request.clientID,
|
|
48
|
+
redirect_uri: request.redirectURI,
|
|
49
|
+
scope: request.scope,
|
|
50
|
+
state: 'state-1',
|
|
51
|
+
code_challenge: challenge,
|
|
52
|
+
code_challenge_method: 'S256',
|
|
53
|
+
resource: `${server.origin}/mcp`,
|
|
54
|
+
}).toString();
|
|
55
|
+
|
|
56
|
+
const authorized = await fetch(authorize, { redirect: 'manual' });
|
|
57
|
+
|
|
58
|
+
const login = new URL(authorized.headers.get('location') ?? '/', server.url);
|
|
59
|
+
|
|
60
|
+
if (login.pathname !== '/login') {
|
|
61
|
+
throw new Error(`authorization did not reach the login page: ${login.href}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const approvalCode = /code (?<code>\w{4}-\w{4})/.exec(server.approvals.at(-1) ?? '')?.groups?.[
|
|
65
|
+
'code'
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
if (approvalCode === undefined) {
|
|
69
|
+
throw new Error('the server printed no approval code');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const signedIn = await fetch(`${server.url}/login`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
redirect: 'manual',
|
|
75
|
+
headers: { origin: server.url, 'content-type': 'application/x-www-form-urlencoded' },
|
|
76
|
+
body: new URLSearchParams({ oauth_query: login.search.slice(1), code: approvalCode }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const consent = new URL(signedIn.headers.get('location') ?? '/', server.url);
|
|
80
|
+
|
|
81
|
+
const cookie = signedIn.headers
|
|
82
|
+
.getSetCookie()
|
|
83
|
+
.map((line) => line.split(';')[0])
|
|
84
|
+
.join('; ');
|
|
85
|
+
|
|
86
|
+
if (consent.pathname !== '/consent') {
|
|
87
|
+
throw new Error(`the approval code did not reach the consent page: ${consent.href}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const form = new URLSearchParams({ oauth_query: consent.search.slice(1), decision: 'approve' });
|
|
91
|
+
|
|
92
|
+
for (const scope of request.ticked) {
|
|
93
|
+
form.append('scope', scope);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const consented = await fetch(`${server.url}/consent`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
redirect: 'manual',
|
|
99
|
+
headers: { origin: server.url, cookie, 'content-type': 'application/x-www-form-urlencoded' },
|
|
100
|
+
body: form,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const location = consented.headers.get('location');
|
|
104
|
+
|
|
105
|
+
if (location === null) {
|
|
106
|
+
throw new Error(`consent did not redirect: ${consented.status} ${await consented.text()}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const callback = new URL(location);
|
|
110
|
+
|
|
111
|
+
const code = callback.searchParams.get('code');
|
|
112
|
+
|
|
113
|
+
if (code === null) {
|
|
114
|
+
throw new Error(`the consent redirect holds no authorization code: ${location}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { callback, code, verifier };
|
|
118
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
import { buildAgentAdapters } from '../agents/build-agent-adapters';
|
|
3
|
+
import { startClaudeHeadlessRun } from '../agents/start-claude-headless-run';
|
|
4
|
+
import { buildExecutionTargets } from '../daemon/build-execution-targets';
|
|
5
|
+
import { startDaemon } from '../daemon/daemon';
|
|
6
|
+
import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
|
|
7
|
+
import {
|
|
8
|
+
daemonPidFile,
|
|
9
|
+
daemonSocketPath,
|
|
10
|
+
dbFile,
|
|
11
|
+
eventsSocketPath,
|
|
12
|
+
loadConfig,
|
|
13
|
+
socketPath,
|
|
14
|
+
} from '../shared/config';
|
|
15
|
+
import { getBuild } from '../shared/get-build';
|
|
16
|
+
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
17
|
+
import { buildSources } from '../sources/build-sources';
|
|
18
|
+
import { collectBuiltinSources } from '../sources/collect-builtin-sources';
|
|
19
|
+
import type { SourceProvider } from '../sources/types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs a daemon for the e2e suite, composed as `atc daemon` composes its
|
|
23
|
+
* own from the config, the home, and the runtime directory, but with the
|
|
24
|
+
* sources `ATC_TEST_SOURCES` selects: `fixture` offers the built-in sources
|
|
25
|
+
* and then a source of git repositories that lists the one repository at
|
|
26
|
+
* `ATC_TEST_FIXTURE_URL`, and `none` offers no sources, as a daemon from
|
|
27
|
+
* before sources does. The fixture source reads `pick upstream` as that
|
|
28
|
+
* repository and `in <scope>` as a scope to list, and appends each listing
|
|
29
|
+
* it serves to `ATC_TEST_SOURCE_LOG` when that is set. It prints `up` once it listens, and SIGTERM stops
|
|
30
|
+
* it.
|
|
31
|
+
*/
|
|
32
|
+
async function main() {
|
|
33
|
+
const config = loadConfig();
|
|
34
|
+
const adapters = buildAgentAdapters(config, startClaudeHeadlessRun);
|
|
35
|
+
|
|
36
|
+
const builtin = buildSources(
|
|
37
|
+
collectBuiltinSources({
|
|
38
|
+
roots: config.dirs.roots,
|
|
39
|
+
githubOwner: config.workspaces.githubOwner,
|
|
40
|
+
ghBin: 'gh',
|
|
41
|
+
homeDir: resolveHomeDir(),
|
|
42
|
+
collectZoxideDirs,
|
|
43
|
+
}),
|
|
44
|
+
config.workspaces.sources,
|
|
45
|
+
).sources;
|
|
46
|
+
|
|
47
|
+
const fixtureURL = process.env['ATC_TEST_FIXTURE_URL'] ?? '';
|
|
48
|
+
const listLog = process.env['ATC_TEST_SOURCE_LOG'];
|
|
49
|
+
|
|
50
|
+
const fixture: SourceProvider = {
|
|
51
|
+
id: 'fixture',
|
|
52
|
+
label: 'fixture repository',
|
|
53
|
+
kind: 'git',
|
|
54
|
+
list: (query, request) => {
|
|
55
|
+
if (listLog !== undefined) {
|
|
56
|
+
appendFileSync(
|
|
57
|
+
listLog,
|
|
58
|
+
`${JSON.stringify({ scope: query.scope ?? null, target: request.target })}\n`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const label = query.scope === undefined ? 'upstream' : `${query.scope}/upstream`;
|
|
63
|
+
|
|
64
|
+
return Promise.resolve({
|
|
65
|
+
candidates: [{ label, detail: 'fixture', pick: { kind: 'git', url: fixtureURL } }],
|
|
66
|
+
scope: query.scope ?? null,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
interpret: (input) => {
|
|
70
|
+
const text = input.trim();
|
|
71
|
+
|
|
72
|
+
if (text === 'pick upstream') {
|
|
73
|
+
return Promise.resolve({ kind: 'git', url: fixtureURL });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (text.startsWith('in ')) {
|
|
77
|
+
return Promise.resolve({ kind: 'browse', scope: text.slice(3) });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Promise.resolve({ kind: 'none' });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handle = await startDaemon({
|
|
85
|
+
socketPath: daemonSocketPath,
|
|
86
|
+
reporterSocketPath: socketPath,
|
|
87
|
+
eventsSocketPath,
|
|
88
|
+
build: getBuild(),
|
|
89
|
+
adapters,
|
|
90
|
+
defaultAgent: config.defaultAgent,
|
|
91
|
+
dbPath: dbFile,
|
|
92
|
+
pidPath: daemonPidFile,
|
|
93
|
+
targets: buildExecutionTargets(config.targets).targets,
|
|
94
|
+
defaultTarget: config.defaultTarget,
|
|
95
|
+
sources: process.env['ATC_TEST_SOURCES'] === 'none' ? [] : [...builtin, fixture],
|
|
96
|
+
gitTransports: config.workspaces.gitTransports,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
process.stdout.write('up\n');
|
|
100
|
+
|
|
101
|
+
process.on('SIGTERM', () => {
|
|
102
|
+
void (async () => {
|
|
103
|
+
await handle.stop();
|
|
104
|
+
|
|
105
|
+
process.exit(0);
|
|
106
|
+
})();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await main();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
interface FakeSystemd {
|
|
6
|
+
// The directory that holds the fake `systemctl` and `systemd-run`, to put
|
|
7
|
+
// first on the PATH of every process that could reach a real one.
|
|
8
|
+
readonly binDir: string;
|
|
9
|
+
|
|
10
|
+
// The directory `ATC_PROC_ROOT` points at, where `placeInUnit` writes.
|
|
11
|
+
readonly procRoot: string;
|
|
12
|
+
|
|
13
|
+
// The arguments of every `systemctl` call, one line per call.
|
|
14
|
+
readonly readSystemctlCalls: () => string[];
|
|
15
|
+
|
|
16
|
+
// The arguments of every `systemd-run` call, one line per call.
|
|
17
|
+
readonly readSystemdRunCalls: () => string[];
|
|
18
|
+
|
|
19
|
+
// Makes `systemctl show -p MainPID` print this pid.
|
|
20
|
+
readonly writeMainPID: (pid: number) => void;
|
|
21
|
+
|
|
22
|
+
// Places the pid in a user service of the given name, as the cgroup of a
|
|
23
|
+
// daemon run by a user manager reads.
|
|
24
|
+
readonly placeInUnit: (pid: number, unit: string) => void;
|
|
25
|
+
|
|
26
|
+
readonly [Symbol.dispose]: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Writes a fake `systemctl` and `systemd-run` that touch no service
|
|
31
|
+
* manager. Without a MainPID, `systemctl show -p MainPID` prints a pid no
|
|
32
|
+
* process holds, so no daemon is ever a unit's main process. With
|
|
33
|
+
* `writeMainPID`, `systemctl restart` stops that pid with SIGTERM and starts
|
|
34
|
+
* `atcCommand daemon` in the background. `systemd-run` runs the command after
|
|
35
|
+
* `--` in the background with only the `--setenv` variables, as a transient
|
|
36
|
+
* unit sees them, and appends its output to the `StandardOutput=append:`
|
|
37
|
+
* file. Every call is logged.
|
|
38
|
+
*/
|
|
39
|
+
export function setupFakeSystemd(atcCommand: readonly string[]): FakeSystemd {
|
|
40
|
+
const root = mkdtempSync(join(tmpdir(), 'atc-fake-systemd-'));
|
|
41
|
+
const binDir = join(root, 'bin');
|
|
42
|
+
const procRoot = join(root, 'proc');
|
|
43
|
+
const atcLine = atcCommand.map((part) => `"${part}"`).join(' ');
|
|
44
|
+
|
|
45
|
+
mkdirSync(binDir);
|
|
46
|
+
mkdirSync(procRoot);
|
|
47
|
+
|
|
48
|
+
writeFileSync(
|
|
49
|
+
join(binDir, 'systemctl'),
|
|
50
|
+
`#!/usr/bin/env bash
|
|
51
|
+
echo "$*" >> "${root}/systemctl.log"
|
|
52
|
+
case "$*" in
|
|
53
|
+
*"show -p MainPID"*)
|
|
54
|
+
if [ -f "${root}/mainpid" ]; then cat "${root}/mainpid"; else echo 999999; fi
|
|
55
|
+
;;
|
|
56
|
+
*"show -p ExecStart"*)
|
|
57
|
+
echo "{ path=/fake/bin/atc ; argv[]=/fake/bin/atc daemon ; }"
|
|
58
|
+
;;
|
|
59
|
+
*"restart "*)
|
|
60
|
+
if [ -f "${root}/mainpid" ]; then
|
|
61
|
+
pid="$(cat "${root}/mainpid")"
|
|
62
|
+
kill "$pid" 2>/dev/null
|
|
63
|
+
for _ in $(seq 100); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
|
64
|
+
${atcLine} daemon > /dev/null 2>&1 < /dev/null &
|
|
65
|
+
fi
|
|
66
|
+
;;
|
|
67
|
+
esac
|
|
68
|
+
exit 0
|
|
69
|
+
`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(binDir, 'systemd-run'),
|
|
74
|
+
`#!/usr/bin/env bash
|
|
75
|
+
echo "$*" >> "${root}/systemd-run.log"
|
|
76
|
+
envargs=()
|
|
77
|
+
log=/dev/null
|
|
78
|
+
while [ $# -gt 0 ]; do
|
|
79
|
+
case "$1" in
|
|
80
|
+
--setenv=*) envargs+=("\${1#--setenv=}"); shift ;;
|
|
81
|
+
--property=StandardOutput=append:*) log="\${1#--property=StandardOutput=append:}"; shift ;;
|
|
82
|
+
--unit) shift 2 ;;
|
|
83
|
+
--) shift; break ;;
|
|
84
|
+
*) shift ;;
|
|
85
|
+
esac
|
|
86
|
+
done
|
|
87
|
+
env -i "\${envargs[@]}" "$@" >> "$log" 2>&1 < /dev/null &
|
|
88
|
+
exit 0
|
|
89
|
+
`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
chmodSync(join(binDir, 'systemctl'), 0o755);
|
|
93
|
+
chmodSync(join(binDir, 'systemd-run'), 0o755);
|
|
94
|
+
|
|
95
|
+
const readLines = (name: string) => {
|
|
96
|
+
try {
|
|
97
|
+
return readFileSync(join(root, name), 'utf8')
|
|
98
|
+
.split('\n')
|
|
99
|
+
.filter((line) => line !== '');
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
binDir,
|
|
107
|
+
procRoot,
|
|
108
|
+
readSystemctlCalls: () => readLines('systemctl.log'),
|
|
109
|
+
readSystemdRunCalls: () => readLines('systemd-run.log'),
|
|
110
|
+
writeMainPID: (pid) => {
|
|
111
|
+
writeFileSync(join(root, 'mainpid'), String(pid));
|
|
112
|
+
},
|
|
113
|
+
placeInUnit: (pid, unit) => {
|
|
114
|
+
mkdirSync(join(procRoot, String(pid)), { recursive: true });
|
|
115
|
+
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(procRoot, String(pid), 'cgroup'),
|
|
118
|
+
`0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
[Symbol.dispose]: () => {
|
|
122
|
+
rmSync(root, { recursive: true, force: true });
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|