@zgeoff/atc 3.4.0 → 3.4.2
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 +2 -2
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/claude-adapter.ts +4 -3
- package/src/agents/codex-adapter.ts +20 -0
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-claude-line-input.ts +44 -0
- package/src/agents/render-sdk-message.ts +57 -0
- package/src/agents/start-claude-headless-run.ts +2 -57
- package/src/agents/with-claude-config-lock.ts +28 -15
- package/src/client/boot-daemon.ts +75 -25
- package/src/client/resolve-workspace-root.ts +10 -5
- package/src/daemon/session-runtime.ts +0 -4
- package/src/gateway.ts +2 -179
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/mcp-http-server.ts +13 -10
- package/src/run-gateway-cli.ts +327 -0
- package/src/run-gateway.ts +49 -18
- package/src/sources/github/run-gh.ts +9 -0
- package/src/test-utils/build-mock-execution-target.ts +25 -0
- package/src/test-utils/build-mock-imp-session-request.ts +53 -0
- package/src/test-utils/build-mock-message-record.ts +7 -3
- 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-brokered-gateway-adapter.ts +50 -0
- package/src/test-utils/build-stub-held-gh.ts +5 -7
- package/src/test-utils/build-stub-held-zoxide.ts +4 -6
- package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -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/create-git-fixture.ts +5 -8
- package/src/test-utils/create-stub-claude.ts +3 -1
- package/src/test-utils/create-stub-echo-claude.ts +21 -0
- package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
- 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/create-stub-systemd.ts +10 -4
- package/src/test-utils/get-only-imp-name.ts +2 -2
- package/src/test-utils/register-test-cleanup.ts +63 -0
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
- package/src/test-utils/setup-mcp-home.ts +24 -13
- package/src/test-utils/setup-mcp-http.ts +15 -7
- package/src/test-utils/setup-temp-dir.ts +7 -6
- package/src/test-utils/start-cut-proxy.ts +6 -2
- package/src/test-utils/start-daemon-process.ts +25 -24
- package/src/test-utils/start-git-http-server.ts +9 -3
- package/src/test-utils/start-mcp-stdio.ts +17 -22
- 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 +10 -8
- package/src/test-utils/start-stub-impd-info.ts +10 -4
- package/src/test-utils/start-stub-impd.ts +8 -3
- package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
- package/src/test-utils/start-stub-mcp-server.ts +7 -2
- package/src/test-utils/start-stub-recording-listener.ts +47 -0
- package/src/test-utils/start-stub-refusing-listener.ts +9 -4
- package/src/test-utils/start-stub-reporter-socket.ts +6 -4
- package/src/test-utils/start-stub-restore-daemon.ts +5 -4
- package/src/test-utils/start-stub-session-bridge.ts +6 -4
- package/src/test-utils/start-stub-silent-server.ts +10 -5
- package/src/test-utils/start-stub-source-daemon.ts +6 -4
- package/src/test-utils/start-stub-stalled-client.ts +61 -0
- package/src/test-utils/start-stub-stalled-listener.ts +13 -9
- package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
- package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
- 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 +20 -6
- package/src/test-utils/start-tui-harness.ts +15 -8
- package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
- /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
package/src/run-gateway.ts
CHANGED
|
@@ -14,6 +14,35 @@ interface GatewayFlags {
|
|
|
14
14
|
readonly stateDir: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// What the gateway reaches outside itself, the process's own by default:
|
|
18
|
+
// the environment its daemon tokens come from, the console, the exit, and
|
|
19
|
+
// the signal listeners that stop it.
|
|
20
|
+
interface GatewayIO {
|
|
21
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
22
|
+
readonly print: (line: string) => void;
|
|
23
|
+
readonly printError: (line: string) => void;
|
|
24
|
+
readonly exit: (code: number) => void;
|
|
25
|
+
readonly registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => Promise<void>) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PROCESS_IO: GatewayIO = {
|
|
29
|
+
env: process.env,
|
|
30
|
+
print: (line) => {
|
|
31
|
+
console.log(line);
|
|
32
|
+
},
|
|
33
|
+
printError: (line) => {
|
|
34
|
+
console.error(line);
|
|
35
|
+
},
|
|
36
|
+
exit: (code) => {
|
|
37
|
+
process.exit(code);
|
|
38
|
+
},
|
|
39
|
+
registerSignal: (signal, listener) => {
|
|
40
|
+
process.on(signal, () => {
|
|
41
|
+
void listener();
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
17
46
|
/**
|
|
18
47
|
* Runs `atc-gateway` in the foreground: serves the MCP tools over HTTP for
|
|
19
48
|
* the daemons the registry lists, routing every call to one of them over
|
|
@@ -23,15 +52,21 @@ interface GatewayFlags {
|
|
|
23
52
|
* registry that fails to load, or a server that fails to start, prints the
|
|
24
53
|
* reason and exits 1.
|
|
25
54
|
*/
|
|
26
|
-
export async function runGateway(
|
|
27
|
-
|
|
55
|
+
export async function runGateway(
|
|
56
|
+
build: string,
|
|
57
|
+
flags: GatewayFlags,
|
|
58
|
+
io: GatewayIO = PROCESS_IO,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
const loaded = loadGatewayRegistry(flags.registryPath, io.env);
|
|
28
61
|
|
|
29
62
|
if (!loaded.ok) {
|
|
30
63
|
for (const error of loaded.errors) {
|
|
31
|
-
|
|
64
|
+
io.printError(`atc-gateway: ${error}`);
|
|
32
65
|
}
|
|
33
66
|
|
|
34
|
-
|
|
67
|
+
io.exit(1);
|
|
68
|
+
|
|
69
|
+
return;
|
|
35
70
|
}
|
|
36
71
|
|
|
37
72
|
mkdirSync(flags.stateDir, { recursive: true });
|
|
@@ -59,19 +94,21 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
|
|
|
59
94
|
// The line carries a client's name, so control and format characters
|
|
60
95
|
// are dropped before it reaches the log.
|
|
61
96
|
printApproval: (line) => {
|
|
62
|
-
|
|
97
|
+
io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
|
|
63
98
|
},
|
|
64
99
|
|
|
65
100
|
// Request lines go to stderr, so stdout keeps the approval lines alone.
|
|
66
101
|
printRequest: (line) => {
|
|
67
|
-
|
|
102
|
+
io.printError(line);
|
|
68
103
|
},
|
|
69
104
|
});
|
|
70
105
|
} catch (error) {
|
|
71
106
|
await gateway.stop();
|
|
72
107
|
|
|
73
|
-
|
|
74
|
-
|
|
108
|
+
io.printError(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
|
|
109
|
+
io.exit(1);
|
|
110
|
+
|
|
111
|
+
return;
|
|
75
112
|
}
|
|
76
113
|
|
|
77
114
|
// The handlers go in before the serving line, so a signal sent once the
|
|
@@ -80,16 +117,10 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
|
|
|
80
117
|
await server.stop();
|
|
81
118
|
await gateway.stop();
|
|
82
119
|
|
|
83
|
-
|
|
120
|
+
io.exit(0);
|
|
84
121
|
};
|
|
85
122
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
process.on('SIGTERM', () => {
|
|
91
|
-
void stopServing();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
|
|
123
|
+
io.registerSignal('SIGINT', stopServing);
|
|
124
|
+
io.registerSignal('SIGTERM', stopServing);
|
|
125
|
+
io.print(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
|
|
95
126
|
}
|
|
@@ -5,6 +5,12 @@ export interface GHRun {
|
|
|
5
5
|
readonly timedOut: boolean;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
interface GHRunOptions {
|
|
9
|
+
// Called with the pid of the started gh right after it starts; that pid
|
|
10
|
+
// is also its process group's id.
|
|
11
|
+
readonly onSpawn?: (pid: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* Runs gh with arguments until it exits or the signal aborts, killing its
|
|
10
16
|
* whole process group on abort, so a wrapper or extension leaves no process
|
|
@@ -15,6 +21,7 @@ export async function runGH(
|
|
|
15
21
|
bin: string,
|
|
16
22
|
signal: Readonly<AbortSignal>,
|
|
17
23
|
args: readonly string[],
|
|
24
|
+
options: GHRunOptions = {},
|
|
18
25
|
): Promise<GHRun> {
|
|
19
26
|
const proc = Bun.spawn([bin, ...args], {
|
|
20
27
|
env: {
|
|
@@ -34,6 +41,8 @@ export async function runGH(
|
|
|
34
41
|
detached: true,
|
|
35
42
|
});
|
|
36
43
|
|
|
44
|
+
options.onSpawn?.(proc.pid);
|
|
45
|
+
|
|
37
46
|
const finished = Promise.all([
|
|
38
47
|
new Response(proc.stdout).text(),
|
|
39
48
|
new Response(proc.stderr).text(),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import type { ExecutionTarget } from '../daemon/build-execution-targets';
|
|
3
|
+
import { mergeDeep } from './merge-deep';
|
|
4
|
+
import type { MockOverrides } from './mock-overrides';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A local-pty execution target with an arbitrary id and identity, no
|
|
8
|
+
* options, and no provider, as a target of a kind this daemon lacks has.
|
|
9
|
+
* Overrides merge into fresh defaults at every depth; a provider override
|
|
10
|
+
* replaces the default whole.
|
|
11
|
+
*/
|
|
12
|
+
export function buildMockExecutionTarget(
|
|
13
|
+
overrides: MockOverrides<ExecutionTarget, keyof ExecutionTarget> = {},
|
|
14
|
+
): ExecutionTarget {
|
|
15
|
+
return mergeDeep<ExecutionTarget>(
|
|
16
|
+
{
|
|
17
|
+
id: faker.word.noun(),
|
|
18
|
+
kind: 'local-pty',
|
|
19
|
+
options: {},
|
|
20
|
+
identity: `local-pty:${faker.string.uuid()}`,
|
|
21
|
+
provider: null,
|
|
22
|
+
},
|
|
23
|
+
overrides,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import type { ImpSessionRequest } from '../daemon/imp-port';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The request that starts a process on an imp.
|
|
6
|
+
*/
|
|
7
|
+
type StartRequest = Extract<ImpSessionRequest, { readonly kind: 'start' }>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The request that attaches to a process already running on an imp.
|
|
11
|
+
*/
|
|
12
|
+
type AttachRequest = Extract<ImpSessionRequest, { readonly kind: 'attach' }>;
|
|
13
|
+
|
|
14
|
+
type AttachOverrides = Partial<AttachRequest> & { readonly kind: 'attach' };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A request that starts `true` on an imp with an empty environment, or, for
|
|
18
|
+
* an override of kind `attach`, one that attaches without waking the imp.
|
|
19
|
+
* Either carries no resume point and no requirements, and an arbitrary imp
|
|
20
|
+
* name, session name, and terminal size; a start's directory is arbitrary
|
|
21
|
+
* too. Each override replaces the default of its field.
|
|
22
|
+
*/
|
|
23
|
+
export function buildMockImpSessionRequest(overrides?: Partial<StartRequest>): StartRequest;
|
|
24
|
+
export function buildMockImpSessionRequest(overrides: AttachOverrides): AttachRequest;
|
|
25
|
+
|
|
26
|
+
export function buildMockImpSessionRequest(
|
|
27
|
+
overrides: Partial<StartRequest> | AttachOverrides = {},
|
|
28
|
+
): ImpSessionRequest {
|
|
29
|
+
const name = `imp-${faker.string.alpha({ length: 6, casing: 'lower' })}`;
|
|
30
|
+
const session = faker.string.uuid();
|
|
31
|
+
const cols = faker.number.int({ min: 20, max: 300 });
|
|
32
|
+
const rows = faker.number.int({ min: 5, max: 100 });
|
|
33
|
+
|
|
34
|
+
return isAttachOverrides(overrides)
|
|
35
|
+
? { name, session, cols, rows, wake: false, ...overrides }
|
|
36
|
+
: {
|
|
37
|
+
kind: 'start',
|
|
38
|
+
name,
|
|
39
|
+
session,
|
|
40
|
+
argv: ['true'],
|
|
41
|
+
env: {},
|
|
42
|
+
cwd: faker.system.directoryPath(),
|
|
43
|
+
cols,
|
|
44
|
+
rows,
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAttachOverrides(
|
|
50
|
+
overrides: Partial<StartRequest> | AttachOverrides,
|
|
51
|
+
): overrides is AttachOverrides {
|
|
52
|
+
return overrides.kind === 'attach';
|
|
53
|
+
}
|
|
@@ -3,13 +3,17 @@ import { toMessageID } from '../shared/to-message-id';
|
|
|
3
3
|
import { toSessionID } from '../shared/to-session-id';
|
|
4
4
|
import type { MessageRecord } from '../store/message-record';
|
|
5
5
|
|
|
6
|
+
type MessageRecordOverrides = {
|
|
7
|
+
readonly [K in keyof MessageRecord]?: MessageRecord[K] | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* A message accepted for a session and not yet delivered: a fresh message
|
|
8
12
|
* id and a fresh atc id, an arbitrary sender, text, and send time, and no
|
|
9
13
|
* agent session id, delivery, answer, or turn. An override replaces the
|
|
10
|
-
* field it names.
|
|
14
|
+
* field it names, and an override of undefined leaves that field out.
|
|
11
15
|
*/
|
|
12
|
-
export function buildMockMessageRecord(overrides:
|
|
16
|
+
export function buildMockMessageRecord(overrides: MessageRecordOverrides = {}): MessageRecord {
|
|
13
17
|
return {
|
|
14
18
|
id: toMessageID(faker.string.uuid()),
|
|
15
19
|
atcID: toSessionID(faker.string.uuid()),
|
|
@@ -17,6 +21,6 @@ export function buildMockMessageRecord(overrides: Partial<MessageRecord> = {}):
|
|
|
17
21
|
text: faker.lorem.sentence(),
|
|
18
22
|
status: 'accepted',
|
|
19
23
|
sentAt: faker.date.past().getTime(),
|
|
20
|
-
...overrides,
|
|
24
|
+
...Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined)),
|
|
21
25
|
};
|
|
22
26
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
3
|
+
import type { StoredEvent } from '../store/state-store';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A row of the event trail as the store reads it back: a `turn-done` event
|
|
7
|
+
* with an arbitrary row id, time, and fresh atc id, and no agent session
|
|
8
|
+
* id, detail, message, or label. Each override replaces the default of its field.
|
|
9
|
+
*/
|
|
10
|
+
export function buildMockStoredEvent(overrides: Partial<StoredEvent> = {}): StoredEvent {
|
|
11
|
+
return {
|
|
12
|
+
id: faker.number.int({ min: 1, max: 1_000_000 }),
|
|
13
|
+
at: faker.date.past().getTime(),
|
|
14
|
+
atcID: toSessionID(faker.string.uuid()),
|
|
15
|
+
agentSessionID: null,
|
|
16
|
+
kind: 'turn-done',
|
|
17
|
+
detail: null,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import type { StoredRow } from '../read-stored-rows';
|
|
3
|
+
import { mergeDeep } from './merge-deep';
|
|
4
|
+
import type { MockOverrides } from './mock-overrides';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A fleet row a daemon has stored, for a live session with a fresh id, an
|
|
8
|
+
* arbitrary name, and no known agent session id. Overrides replace the
|
|
9
|
+
* defaults field by field.
|
|
10
|
+
*/
|
|
11
|
+
export function buildMockStoredRow(
|
|
12
|
+
overrides: MockOverrides<StoredRow, keyof StoredRow> = {},
|
|
13
|
+
): StoredRow {
|
|
14
|
+
return mergeDeep<StoredRow>(
|
|
15
|
+
{
|
|
16
|
+
id: faker.string.uuid(),
|
|
17
|
+
name: faker.word.noun(),
|
|
18
|
+
exited: false,
|
|
19
|
+
agentSessionID: null,
|
|
20
|
+
},
|
|
21
|
+
overrides,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
2
|
+
import { buildMockAgentAdapter } from './build-mock-agent-adapter';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An agent adapter `glm` for daemon tests that only starts behind impd's
|
|
6
|
+
* broker and takes the credential of a GLM gateway from it: the auth
|
|
7
|
+
* profile `glm`, a custom bearer secret `glm` for api.z.ai, behind the
|
|
8
|
+
* gateway at https://api.z.ai/api/anthropic with the placeholder
|
|
9
|
+
* `ANTHROPIC_AUTH_TOKEN`. Every guest spawn sleeps, with the binding's
|
|
10
|
+
* variables, when it has one, and `CLAUDE_CONFIG_DIR` set to
|
|
11
|
+
* `claude-config` in the guest folder. Every other member is the plain
|
|
12
|
+
* agent adapter's.
|
|
13
|
+
*/
|
|
14
|
+
export function buildStubBrokeredGatewayAdapter(): AgentAdapter {
|
|
15
|
+
return buildMockAgentAdapter({
|
|
16
|
+
id: 'glm',
|
|
17
|
+
planGuestSpawn: (_opts, guest) => ({
|
|
18
|
+
bin: 'sleep',
|
|
19
|
+
args: ['30'],
|
|
20
|
+
files: {},
|
|
21
|
+
env: { ...guest.auth?.env, CLAUDE_CONFIG_DIR: `${guest.dir}/claude-config` },
|
|
22
|
+
}),
|
|
23
|
+
findAuthSelection: () => ({
|
|
24
|
+
brokerRequired: true,
|
|
25
|
+
gateway: {
|
|
26
|
+
id: 'glm',
|
|
27
|
+
baseURL: 'https://api.z.ai/api/anthropic',
|
|
28
|
+
auth: {
|
|
29
|
+
profiles: ['glm'],
|
|
30
|
+
placeholderEnv: { ANTHROPIC_AUTH_TOKEN: 'imp-broker-placeholder' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
profiles: new Map([
|
|
34
|
+
[
|
|
35
|
+
'glm',
|
|
36
|
+
{
|
|
37
|
+
name: 'glm',
|
|
38
|
+
secret: 'glm',
|
|
39
|
+
kind: 'custom',
|
|
40
|
+
host: 'api.z.ai',
|
|
41
|
+
header: 'authorization',
|
|
42
|
+
scheme: 'bearer',
|
|
43
|
+
env: {},
|
|
44
|
+
dependencies: [],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
]),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The script of a stand-in `gh` whose repository listing waits for the
|
|
3
|
-
* test. `gh config …` prints `https`. Any other command
|
|
4
|
-
* `$HOME/gh-
|
|
5
|
-
*
|
|
3
|
+
* test. `gh config …` prints `https`. Any other command waits while
|
|
4
|
+
* `$HOME/gh-hold` exists, touching `$HOME/gh-held` on each pass of the
|
|
5
|
+
* wait, so the marker appears only once the stand-in is held, then prints
|
|
6
|
+
* one repository, `me/dots`, in the JSON shape `gh repo list` prints.
|
|
6
7
|
*/
|
|
7
8
|
export function buildStubHeldGH(): string {
|
|
8
9
|
return `#!/bin/sh
|
|
9
10
|
case "$1" in
|
|
10
11
|
config) echo https ;;
|
|
11
12
|
*)
|
|
12
|
-
|
|
13
|
-
touch "$HOME/gh-held"
|
|
14
|
-
while [ -f "$HOME/gh-hold" ]; do sleep 0.05; done
|
|
15
|
-
fi
|
|
13
|
+
while [ -f "$HOME/gh-hold" ]; do touch "$HOME/gh-held"; sleep 0.05; done
|
|
16
14
|
echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
|
|
17
15
|
esac
|
|
18
16
|
`;
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The script of a stand-in `zoxide` that lists no directories, and waits
|
|
3
|
-
* for the test first: while `$HOME/zoxide-hold` exists, it
|
|
4
|
-
* `$HOME/zoxide-held`
|
|
3
|
+
* for the test first: while `$HOME/zoxide-hold` exists, it waits for the
|
|
4
|
+
* file to go, touching `$HOME/zoxide-held` on each pass of the wait, so the
|
|
5
|
+
* marker appears only once the stand-in is held.
|
|
5
6
|
*/
|
|
6
7
|
export function buildStubHeldZoxide(): string {
|
|
7
8
|
return `#!/bin/sh
|
|
8
|
-
|
|
9
|
-
touch "$HOME/zoxide-held"
|
|
10
|
-
while [ -f "$HOME/zoxide-hold" ]; do sleep 0.05; done
|
|
11
|
-
fi
|
|
9
|
+
while [ -f "$HOME/zoxide-hold" ]; do touch "$HOME/zoxide-held"; sleep 0.05; done
|
|
12
10
|
`;
|
|
13
11
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script of a stand-in `atc mcp` server on stdio that answers each line
|
|
3
|
+
* it reads with the next of `replies`, printed as given, whether JSON or
|
|
4
|
+
* not, and reads stdin without answering once the replies run out. It
|
|
5
|
+
* exits without a reply when stdin ends before a line arrives. Before
|
|
6
|
+
* it reads anything, it records its pid in the file at its own path with
|
|
7
|
+
* `.pid` appended, moving the file into place so it appears whole.
|
|
8
|
+
*/
|
|
9
|
+
export function buildStubMCPStdioServer(replies: readonly string[]): string {
|
|
10
|
+
const answers = replies
|
|
11
|
+
.map((reply) => `read -r _ || exit 0\nprintf '%s\\n' ${toShellLiteral(reply)}\n`)
|
|
12
|
+
.join('');
|
|
13
|
+
|
|
14
|
+
return `#!/usr/bin/env bash\necho $$ > "$0.pid.tmp"\nmv "$0.pid.tmp" "$0.pid"\n${answers}exec cat > /dev/null\n`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Quotes a value for the shell as one word, whatever it holds.
|
|
18
|
+
function toShellLiteral(value: string): string {
|
|
19
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script of a stand-in git filter driver that records each run by
|
|
3
|
+
* appending the line `ran` to the record file and passes its input through
|
|
4
|
+
* unchanged, as a smudge filter that changes nothing does, so a test can
|
|
5
|
+
* tell whether git ran a filter the host configured.
|
|
6
|
+
*/
|
|
7
|
+
export function buildStubRecordingFilter(record: string): string {
|
|
8
|
+
return `#!/bin/sh\necho ran >> '${record}'\ncat\n`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script of a stand-in `git` that records each run by appending the
|
|
3
|
+
* line `ran` to the record file, then exits 0 without doing anything else,
|
|
4
|
+
* so a test can tell whether the code under test ran git at all.
|
|
5
|
+
*/
|
|
6
|
+
export function buildStubRecordingGit(record: string): string {
|
|
7
|
+
return `#!/bin/sh\necho ran >> '${record}'\n`;
|
|
8
|
+
}
|
|
@@ -22,17 +22,16 @@ interface GitFixtureConfig {
|
|
|
22
22
|
*
|
|
23
23
|
* The first call in a process builds a template pair under the test home,
|
|
24
24
|
* and each call copies it, so a fixture costs a copy instead of a run of
|
|
25
|
-
* git commands.
|
|
26
|
-
*
|
|
25
|
+
* git commands. The directory is removed once the current test finishes,
|
|
26
|
+
* so it must run inside a test; disposal removes it sooner, and a second
|
|
27
|
+
* removal does nothing.
|
|
27
28
|
*/
|
|
28
29
|
export async function createGitFixture(config: GitFixtureConfig = {}) {
|
|
29
30
|
const env = buildGitEnv(process.env);
|
|
30
31
|
|
|
31
32
|
const template = await resolveTemplate();
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const tmp = stack.use(setupTempDir(config.prefix ?? 'atc-git-fixture-'));
|
|
34
|
+
const tmp = setupTempDir(config.prefix ?? 'atc-git-fixture-');
|
|
36
35
|
const upstream = join(tmp.dir, 'upstream.git');
|
|
37
36
|
const work = join(tmp.dir, 'work');
|
|
38
37
|
|
|
@@ -51,15 +50,13 @@ export async function createGitFixture(config: GitFixtureConfig = {}) {
|
|
|
51
50
|
),
|
|
52
51
|
);
|
|
53
52
|
|
|
54
|
-
const owned = stack.move();
|
|
55
|
-
|
|
56
53
|
return {
|
|
57
54
|
dir: tmp.dir,
|
|
58
55
|
env,
|
|
59
56
|
upstream,
|
|
60
57
|
work,
|
|
61
58
|
sha: template.sha,
|
|
62
|
-
[Symbol.asyncDispose]:
|
|
59
|
+
[Symbol.asyncDispose]: tmp[Symbol.asyncDispose],
|
|
63
60
|
};
|
|
64
61
|
}
|
|
65
62
|
|
|
@@ -29,7 +29,8 @@ interface StubClaudeConfig {
|
|
|
29
29
|
* first stub to find it removes it; `fake-claude-tap` runs `atc tap` into
|
|
30
30
|
* `$HOME/tap.jsonl`; `fake-claude-events.jsonl` holds more hook payloads to
|
|
31
31
|
* report, one per line, after the `Notification`; `fake-claude-exit` exits
|
|
32
|
-
* once they are reported
|
|
32
|
+
* once they are reported; `fake-claude-composer-last` runs the composer once
|
|
33
|
+
* they are reported.
|
|
33
34
|
*/
|
|
34
35
|
export function createStubClaude(dir: string, config: StubClaudeConfig): string {
|
|
35
36
|
const atc = config.atc.map((part) => `"${part}"`).join(' ');
|
|
@@ -69,6 +70,7 @@ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
|
|
|
69
70
|
done < "$HOME/fake-claude-events.jsonl"
|
|
70
71
|
fi
|
|
71
72
|
if [ -f "$HOME/fake-claude-exit" ]; then exit 0; fi
|
|
73
|
+
if [ -f "$HOME/fake-claude-composer-last" ]; then exec "${process.execPath}" "${config.composer}"; fi
|
|
72
74
|
while read -r line; do echo "GOT:$line"; done
|
|
73
75
|
`,
|
|
74
76
|
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createStubBin } from './create-stub-bin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a stand-in for the Claude CLI as `fake-claude` under the directory
|
|
5
|
+
* and returns its path. It prints `UP:<pid>`, then reads one line at a time
|
|
6
|
+
* and prints `GOT:<line>:<pid>` after each, so a test sees which process
|
|
7
|
+
* took its input. A `quit` line ends it with exit code 3.
|
|
8
|
+
*/
|
|
9
|
+
export function createStubEchoClaude(dir: string): string {
|
|
10
|
+
return createStubBin(
|
|
11
|
+
dir,
|
|
12
|
+
'fake-claude',
|
|
13
|
+
`#!/usr/bin/env bash
|
|
14
|
+
echo "UP:$$"
|
|
15
|
+
while read -r line; do
|
|
16
|
+
if [ "$line" = "quit" ]; then exit 3; fi
|
|
17
|
+
echo "GOT:$line:$$"
|
|
18
|
+
done
|
|
19
|
+
`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
import { ImpPortError } from '../daemon/imp-port-error';
|
|
30
30
|
import { isImpNameAllowed } from '../daemon/is-imp-name-allowed';
|
|
31
31
|
import { isBrokerVariable } from '../shared/is-broker-variable';
|
|
32
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
32
33
|
|
|
33
34
|
// impd keeps exactly this many bytes of each generation's output.
|
|
34
35
|
const RING_BYTES = 262_144;
|
|
@@ -51,7 +52,7 @@ interface StubCommandHold {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
|
-
* The impd stand-in the factory below
|
|
55
|
+
* The impd stand-in the factory below creates, one per call.
|
|
55
56
|
*/
|
|
56
57
|
class StubImpPort implements ImpPort {
|
|
57
58
|
// Every port call, in order, as `<call> <imp> [<detail>]`.
|
|
@@ -60,6 +61,9 @@ class StubImpPort implements ImpPort {
|
|
|
60
61
|
// Every session request the port received, in order.
|
|
61
62
|
readonly sessionRequests: ImpSessionRequest[] = [];
|
|
62
63
|
|
|
64
|
+
// The spec of every imp create the port received, in order.
|
|
65
|
+
readonly createSpecs: ImpCreateSpec[] = [];
|
|
66
|
+
|
|
63
67
|
features: ImpFeatures = {
|
|
64
68
|
sessionOffsets: true,
|
|
65
69
|
leases: true,
|
|
@@ -117,6 +121,9 @@ class StubImpPort implements ImpPort {
|
|
|
117
121
|
// Feature reads still to fail as an unreachable impd before they answer.
|
|
118
122
|
private featureFailures = 0;
|
|
119
123
|
|
|
124
|
+
// Feature reads that failed as an unreachable impd.
|
|
125
|
+
private failedFeatureReads = 0;
|
|
126
|
+
|
|
120
127
|
// Session connections whose opening still throws before it returns, as
|
|
121
128
|
// an invalid authorization header makes the socket's constructor throw.
|
|
122
129
|
private openFailures = 0;
|
|
@@ -153,6 +160,9 @@ class StubImpPort implements ImpPort {
|
|
|
153
160
|
readonly done: PromiseWithResolvers<void>;
|
|
154
161
|
} | null = null;
|
|
155
162
|
|
|
163
|
+
// How many commands wait on a command hold.
|
|
164
|
+
private heldCommands = 0;
|
|
165
|
+
|
|
156
166
|
// Commands whose argv holds this text exit 1 without running, or null.
|
|
157
167
|
private commandFailure: string | null = null;
|
|
158
168
|
|
|
@@ -191,6 +201,7 @@ class StubImpPort implements ImpPort {
|
|
|
191
201
|
|
|
192
202
|
if (this.featureFailures > 0) {
|
|
193
203
|
this.featureFailures -= 1;
|
|
204
|
+
this.failedFeatureReads += 1;
|
|
194
205
|
|
|
195
206
|
return Promise.reject(new ImpPortError('UNREACHABLE', 'impd did not answer'));
|
|
196
207
|
}
|
|
@@ -294,6 +305,7 @@ class StubImpPort implements ImpPort {
|
|
|
294
305
|
|
|
295
306
|
createImp(spec: ImpCreateSpec): Promise<ImpView> {
|
|
296
307
|
this.calls.push(`imps.create ${spec.name}`);
|
|
308
|
+
this.createSpecs.push(spec);
|
|
297
309
|
|
|
298
310
|
if (this.imps.has(spec.name)) {
|
|
299
311
|
return Promise.reject(
|
|
@@ -591,7 +603,11 @@ class StubImpPort implements ImpPort {
|
|
|
591
603
|
if (hold !== null && line.includes(hold.match)) {
|
|
592
604
|
hold.entered.resolve(line);
|
|
593
605
|
|
|
606
|
+
this.heldCommands += 1;
|
|
607
|
+
|
|
594
608
|
await hold.done.promise;
|
|
609
|
+
|
|
610
|
+
this.heldCommands -= 1;
|
|
595
611
|
}
|
|
596
612
|
|
|
597
613
|
if (this.commandFailure !== null && line.includes(this.commandFailure)) {
|
|
@@ -1054,6 +1070,11 @@ class StubImpPort implements ImpPort {
|
|
|
1054
1070
|
return { entered: hold.entered.promise, stop, [Symbol.dispose]: stop };
|
|
1055
1071
|
}
|
|
1056
1072
|
|
|
1073
|
+
// How many commands wait for their command hold to stop.
|
|
1074
|
+
countHeldCommands(): number {
|
|
1075
|
+
return this.heldCommands;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1057
1078
|
// Lets every command the active hold holds run, and the next at once.
|
|
1058
1079
|
stopCommandHold(): void {
|
|
1059
1080
|
const hold = this.commandHold;
|
|
@@ -1092,6 +1113,11 @@ class StubImpPort implements ImpPort {
|
|
|
1092
1113
|
this.featureFailures = count;
|
|
1093
1114
|
}
|
|
1094
1115
|
|
|
1116
|
+
// How many feature reads failed as an unreachable impd.
|
|
1117
|
+
countFailedFeatureReads(): number {
|
|
1118
|
+
return this.failedFeatureReads;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1095
1121
|
/**
|
|
1096
1122
|
* Refuses the next session request with an impd code and its data, or the
|
|
1097
1123
|
* message of the refusal when it carries one instead.
|
|
@@ -1604,7 +1630,7 @@ class StubImpPort implements ImpPort {
|
|
|
1604
1630
|
}
|
|
1605
1631
|
|
|
1606
1632
|
/**
|
|
1607
|
-
*
|
|
1633
|
+
* Creates an in-process stand-in for impd behind the imp port, calling impd
|
|
1608
1634
|
* as the principal, `token:atc` when absent. Each imp is a set of
|
|
1609
1635
|
* real `bun-pty` processes on this machine; a sleeping imp stops them with
|
|
1610
1636
|
* SIGSTOP and a wake continues them, so a memory wake keeps each process
|
|
@@ -1622,10 +1648,18 @@ class StubImpPort implements ImpPort {
|
|
|
1622
1648
|
* `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
|
|
1623
1649
|
* while the broker fails or the imp holds no grant, when the start sets a
|
|
1624
1650
|
* broker variable, and when it would join a process that started without
|
|
1625
|
-
* the broker required.
|
|
1651
|
+
* the broker required. Once the current test finishes, the stand-in kills
|
|
1652
|
+
* every process and stops every forward it holds, so it must be created inside
|
|
1653
|
+
* a test; disposal does so sooner, and a second disposal does nothing.
|
|
1626
1654
|
*/
|
|
1627
|
-
export function
|
|
1628
|
-
|
|
1655
|
+
export function createStubImpPort(principal = 'token:atc'): StubImpPort {
|
|
1656
|
+
const port = new StubImpPort(principal);
|
|
1657
|
+
|
|
1658
|
+
registerTestCleanup(() => {
|
|
1659
|
+
port[Symbol.dispose]();
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
return port;
|
|
1629
1663
|
}
|
|
1630
1664
|
|
|
1631
1665
|
interface StubLease {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createStubBin } from './create-stub-bin';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a stand-in for the Codex CLI as `fake-codex` under the directory
|
|
6
|
+
* and returns its path. Each start appends one line to `codex-starts.log`
|
|
7
|
+
* under the same directory: the `CODEX_HOME` it started with, then its
|
|
8
|
+
* arguments, separated by spaces. It then stays up without reporting
|
|
9
|
+
* anything. The log exists only once a start has run, so a test proves that
|
|
10
|
+
* no harness started by its absence.
|
|
11
|
+
*/
|
|
12
|
+
export function createStubRecordingCodex(dir: string): string {
|
|
13
|
+
const log = join(dir, 'codex-starts.log');
|
|
14
|
+
|
|
15
|
+
return createStubBin(
|
|
16
|
+
dir,
|
|
17
|
+
'fake-codex',
|
|
18
|
+
`#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\nexec sleep 30\n`,
|
|
19
|
+
);
|
|
20
|
+
}
|