@zgeoff/atc 3.4.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/codex-adapter.ts +20 -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-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-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-echo-claude.ts +21 -0
- package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +30 -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-legacy-daemon.ts +1 -1
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- 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 +5 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -153,6 +157,9 @@ class StubImpPort implements ImpPort {
|
|
|
153
157
|
readonly done: PromiseWithResolvers<void>;
|
|
154
158
|
} | null = null;
|
|
155
159
|
|
|
160
|
+
// How many commands wait on a command hold.
|
|
161
|
+
private heldCommands = 0;
|
|
162
|
+
|
|
156
163
|
// Commands whose argv holds this text exit 1 without running, or null.
|
|
157
164
|
private commandFailure: string | null = null;
|
|
158
165
|
|
|
@@ -294,6 +301,7 @@ class StubImpPort implements ImpPort {
|
|
|
294
301
|
|
|
295
302
|
createImp(spec: ImpCreateSpec): Promise<ImpView> {
|
|
296
303
|
this.calls.push(`imps.create ${spec.name}`);
|
|
304
|
+
this.createSpecs.push(spec);
|
|
297
305
|
|
|
298
306
|
if (this.imps.has(spec.name)) {
|
|
299
307
|
return Promise.reject(
|
|
@@ -591,7 +599,11 @@ class StubImpPort implements ImpPort {
|
|
|
591
599
|
if (hold !== null && line.includes(hold.match)) {
|
|
592
600
|
hold.entered.resolve(line);
|
|
593
601
|
|
|
602
|
+
this.heldCommands += 1;
|
|
603
|
+
|
|
594
604
|
await hold.done.promise;
|
|
605
|
+
|
|
606
|
+
this.heldCommands -= 1;
|
|
595
607
|
}
|
|
596
608
|
|
|
597
609
|
if (this.commandFailure !== null && line.includes(this.commandFailure)) {
|
|
@@ -1054,6 +1066,11 @@ class StubImpPort implements ImpPort {
|
|
|
1054
1066
|
return { entered: hold.entered.promise, stop, [Symbol.dispose]: stop };
|
|
1055
1067
|
}
|
|
1056
1068
|
|
|
1069
|
+
// How many commands wait for their command hold to stop.
|
|
1070
|
+
countHeldCommands(): number {
|
|
1071
|
+
return this.heldCommands;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1057
1074
|
// Lets every command the active hold holds run, and the next at once.
|
|
1058
1075
|
stopCommandHold(): void {
|
|
1059
1076
|
const hold = this.commandHold;
|
|
@@ -1604,7 +1621,7 @@ class StubImpPort implements ImpPort {
|
|
|
1604
1621
|
}
|
|
1605
1622
|
|
|
1606
1623
|
/**
|
|
1607
|
-
*
|
|
1624
|
+
* Creates an in-process stand-in for impd behind the imp port, calling impd
|
|
1608
1625
|
* as the principal, `token:atc` when absent. Each imp is a set of
|
|
1609
1626
|
* real `bun-pty` processes on this machine; a sleeping imp stops them with
|
|
1610
1627
|
* SIGSTOP and a wake continues them, so a memory wake keeps each process
|
|
@@ -1622,10 +1639,18 @@ class StubImpPort implements ImpPort {
|
|
|
1622
1639
|
* `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
|
|
1623
1640
|
* while the broker fails or the imp holds no grant, when the start sets a
|
|
1624
1641
|
* broker variable, and when it would join a process that started without
|
|
1625
|
-
* the broker required.
|
|
1642
|
+
* the broker required. Once the current test finishes, the stand-in kills
|
|
1643
|
+
* every process and stops every forward it holds, so it must be created inside
|
|
1644
|
+
* a test; disposal does so sooner, and a second disposal does nothing.
|
|
1626
1645
|
*/
|
|
1627
|
-
export function
|
|
1628
|
-
|
|
1646
|
+
export function createStubImpPort(principal = 'token:atc'): StubImpPort {
|
|
1647
|
+
const port = new StubImpPort(principal);
|
|
1648
|
+
|
|
1649
|
+
registerTestCleanup(() => {
|
|
1650
|
+
port[Symbol.dispose]();
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
return port;
|
|
1629
1654
|
}
|
|
1630
1655
|
|
|
1631
1656
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createStubBin } from './create-stub-bin';
|
|
3
|
+
|
|
4
|
+
interface StubSharedServerCodexConfig {
|
|
5
|
+
// The command atc runs as; the stub reports each hook through its
|
|
6
|
+
// `hook-report --agent codex`, as the hook entries atc prints for Codex do.
|
|
7
|
+
readonly atc: readonly string[];
|
|
8
|
+
|
|
9
|
+
// The composer script the stub finishes in.
|
|
10
|
+
readonly composer: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a stand-in for a Codex CLI that shares one background server
|
|
15
|
+
* between its terminals, as `fake-codex` under the directory, and returns
|
|
16
|
+
* its path. Without `--no-daemon`, the first start records its own
|
|
17
|
+
* `ATC_SESSION_ID` and `ATC_SOCKET` in `codex-shared-server.env` under the
|
|
18
|
+
* directory, as the server it starts keeps its environment, and every start
|
|
19
|
+
* runs its hooks with the recorded pair, as Codex runs hooks inside that
|
|
20
|
+
* server. With `--no-daemon`, a start runs its hooks with its own pair. The
|
|
21
|
+
* stub prints its arguments, reports a `SessionStart` for its thread, which
|
|
22
|
+
* is `fake-thread-<its own ATC_SESSION_ID>` or the id after `resume`, then
|
|
23
|
+
* a `Stop` whose last message is `done <thread>`, then runs the composer.
|
|
24
|
+
*/
|
|
25
|
+
export function createStubSharedServerCodex(
|
|
26
|
+
dir: string,
|
|
27
|
+
config: StubSharedServerCodexConfig,
|
|
28
|
+
): string {
|
|
29
|
+
const report = `${config.atc.map((part) => `"${part}"`).join(' ')} hook-report --agent codex`;
|
|
30
|
+
const server = join(dir, 'codex-shared-server.env');
|
|
31
|
+
|
|
32
|
+
return createStubBin(
|
|
33
|
+
dir,
|
|
34
|
+
'fake-codex',
|
|
35
|
+
`#!/usr/bin/env bash
|
|
36
|
+
echo "FAKE_CODEX_UP args: $*"
|
|
37
|
+
thread="fake-thread-$ATC_SESSION_ID"
|
|
38
|
+
source=startup
|
|
39
|
+
prev=''
|
|
40
|
+
for arg in "$@"; do
|
|
41
|
+
if [ "$prev" = resume ]; then thread="$arg"; source=resume; fi
|
|
42
|
+
prev="$arg"
|
|
43
|
+
done
|
|
44
|
+
case " $* " in
|
|
45
|
+
*' --no-daemon '*) ;;
|
|
46
|
+
*)
|
|
47
|
+
(set -o noclobber; printf 'ATC_SESSION_ID=%q\\nATC_SOCKET=%q\\n' "$ATC_SESSION_ID" "$ATC_SOCKET" > '${server}') 2>/dev/null
|
|
48
|
+
. '${server}'
|
|
49
|
+
;;
|
|
50
|
+
esac
|
|
51
|
+
printf '{"hook_event_name":"SessionStart","session_id":"%s","source":"%s"}' "$thread" "$source" | ${report}
|
|
52
|
+
printf '{"hook_event_name":"Stop","session_id":"%s","last_assistant_message":"done %s"}' "$thread" "$thread" | ${report}
|
|
53
|
+
exec "${process.execPath}" "${config.composer}"
|
|
54
|
+
`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
4
5
|
|
|
5
6
|
interface StubSystemd {
|
|
6
7
|
// The directory that holds the stand-in `systemctl` and `systemd-run`, to put
|
|
@@ -34,10 +35,17 @@ interface StubSystemd {
|
|
|
34
35
|
* `atcCommand daemon` in the background. `systemd-run` runs the command after
|
|
35
36
|
* `--` in the background with only the `--setenv` variables, as a transient
|
|
36
37
|
* unit sees them, and appends its output to the `StandardOutput=append:`
|
|
37
|
-
* file. Every call is logged.
|
|
38
|
+
* file. Every call is logged. The stand-ins' directory is removed once the
|
|
39
|
+
* current test finishes, so it must run inside a test; disposal removes it
|
|
40
|
+
* sooner, and a second removal does nothing.
|
|
38
41
|
*/
|
|
39
42
|
export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
|
|
40
43
|
const root = mkdtempSync(join(tmpdir(), 'atc-stub-systemd-'));
|
|
44
|
+
|
|
45
|
+
const remove = registerTestCleanup(() => {
|
|
46
|
+
rmSync(root, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
41
49
|
const binDir = join(root, 'bin');
|
|
42
50
|
const procRoot = join(root, 'proc');
|
|
43
51
|
const atcLine = atcCommand.map((part) => `"${part}"`).join(' ');
|
|
@@ -118,8 +126,6 @@ exit 0
|
|
|
118
126
|
`0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
|
|
119
127
|
);
|
|
120
128
|
},
|
|
121
|
-
[Symbol.dispose]:
|
|
122
|
-
rmSync(root, { recursive: true, force: true });
|
|
123
|
-
},
|
|
129
|
+
[Symbol.dispose]: remove,
|
|
124
130
|
};
|
|
125
131
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { createStubImpPort } from './create-stub-imp-port';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Returns the name of the one imp a stub imp port holds, and throws when
|
|
@@ -6,7 +6,7 @@ import type { buildStubImpPort } from './build-stub-imp-port';
|
|
|
6
6
|
* reads another's.
|
|
7
7
|
*/
|
|
8
8
|
export function getOnlyImpName(
|
|
9
|
-
port: Readonly<Pick<ReturnType<typeof
|
|
9
|
+
port: Readonly<Pick<ReturnType<typeof createStubImpPort>, 'collectImpNames'>>,
|
|
10
10
|
): string {
|
|
11
11
|
const names = port.collectImpNames();
|
|
12
12
|
const [name] = names;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { onTestFinished } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// The releases the current test registered, in registration order; null
|
|
4
|
+
// before its first registration and once its hook has started.
|
|
5
|
+
let pending: (() => unknown)[] | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers the release of a resource a test just acquired, to run once the
|
|
9
|
+
* current test finishes, and returns that release for the resource's own
|
|
10
|
+
* stop and dispose members. Every release a test registers runs from one
|
|
11
|
+
* hook, last registered first, as `using` declarations release, so a
|
|
12
|
+
* directory made before a process that lives in it is removed only after
|
|
13
|
+
* that process stops. A release that throws stops none of the others: the
|
|
14
|
+
* hook rethrows once every release has run, as an `AggregateError` when
|
|
15
|
+
* more than one threw. The returned release runs its callback once: every
|
|
16
|
+
* later call, from the hook or from the test, returns what the first call
|
|
17
|
+
* returned. Bun runs the hook after every `afterEach`, so a release works
|
|
18
|
+
* from the paths and handles it captured. Throws outside a test, where
|
|
19
|
+
* nothing would run the hook.
|
|
20
|
+
*/
|
|
21
|
+
export function registerTestCleanup<T>(release: () => T): () => T {
|
|
22
|
+
let released: { readonly value: T } | null = null;
|
|
23
|
+
|
|
24
|
+
const releaseOnce = (): T => {
|
|
25
|
+
released ??= { value: release() };
|
|
26
|
+
|
|
27
|
+
return released.value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (pending === null) {
|
|
31
|
+
onTestFinished(releasePending);
|
|
32
|
+
|
|
33
|
+
pending = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pending.push(releaseOnce);
|
|
37
|
+
|
|
38
|
+
return releaseOnce;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function releasePending(): Promise<void> {
|
|
42
|
+
const releases = pending ?? [];
|
|
43
|
+
|
|
44
|
+
pending = null;
|
|
45
|
+
|
|
46
|
+
const errors: unknown[] = [];
|
|
47
|
+
|
|
48
|
+
for (const release of releases.toReversed()) {
|
|
49
|
+
try {
|
|
50
|
+
await release();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
errors.push(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (errors.length === 1) {
|
|
57
|
+
throw errors[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (errors.length > 1) {
|
|
61
|
+
throw new AggregateError(errors, 'more than one test cleanup failed');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -23,7 +23,7 @@ function main() {
|
|
|
23
23
|
throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1 });
|
|
26
|
+
startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1, owner: 'caller' });
|
|
27
27
|
|
|
28
28
|
const session = Bun.spawn(['sleep', '60']);
|
|
29
29
|
|
|
@@ -18,6 +18,9 @@ interface MCPAuthorizationRequest {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
interface MCPAuthorization {
|
|
21
|
+
// The consent page the approval code sent the browser to.
|
|
22
|
+
readonly consent: URL;
|
|
23
|
+
|
|
21
24
|
// Where the consent page sent the browser back to.
|
|
22
25
|
readonly callback: URL;
|
|
23
26
|
readonly code: string;
|
|
@@ -29,9 +32,9 @@ interface MCPAuthorization {
|
|
|
29
32
|
* browser and the operator do: opens the authorization request with PKCE,
|
|
30
33
|
* types the approval code the server printed into the login page, and ticks
|
|
31
34
|
* the given scopes on the consent page. Each request starts with no cookies,
|
|
32
|
-
* as a fresh browser would. Returns
|
|
33
|
-
* request needs, and throws when any step's
|
|
34
|
-
* step depends on.
|
|
35
|
+
* as a fresh browser would. Returns the consent page it passed, where the
|
|
36
|
+
* browser landed, and what a token request needs, and throws when any step's
|
|
37
|
+
* response lacks the value the next step depends on.
|
|
35
38
|
*/
|
|
36
39
|
export async function runMCPAuthorization(
|
|
37
40
|
server: MCPAuthorizationServer,
|
|
@@ -114,5 +117,5 @@ export async function runMCPAuthorization(
|
|
|
114
117
|
throw new Error(`the consent redirect holds no authorization code: ${location}`);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
return { callback, code, verifier };
|
|
120
|
+
return { consent, callback, code, verifier };
|
|
118
121
|
}
|