@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.
Files changed (63) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/codex-adapter.ts +20 -0
  4. package/src/agents/render-sdk-message.ts +57 -0
  5. package/src/agents/start-claude-headless-run.ts +2 -57
  6. package/src/agents/with-claude-config-lock.ts +28 -15
  7. package/src/client/boot-daemon.ts +75 -25
  8. package/src/client/resolve-workspace-root.ts +10 -5
  9. package/src/daemon/session-runtime.ts +0 -4
  10. package/src/gateway.ts +2 -179
  11. package/src/mcp-http-server.ts +13 -10
  12. package/src/run-gateway-cli.ts +327 -0
  13. package/src/run-gateway.ts +49 -18
  14. package/src/sources/github/run-gh.ts +9 -0
  15. package/src/test-utils/build-mock-execution-target.ts +25 -0
  16. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  17. package/src/test-utils/build-mock-message-record.ts +7 -3
  18. package/src/test-utils/build-mock-stored-event.ts +20 -0
  19. package/src/test-utils/build-mock-stored-row.ts +23 -0
  20. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  21. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  22. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  23. package/src/test-utils/build-stub-recording-git.ts +8 -0
  24. package/src/test-utils/create-git-fixture.ts +5 -8
  25. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  26. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +30 -5
  27. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  28. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  29. package/src/test-utils/create-stub-systemd.ts +10 -4
  30. package/src/test-utils/get-only-imp-name.ts +2 -2
  31. package/src/test-utils/register-test-cleanup.ts +63 -0
  32. package/src/test-utils/run-legacy-daemon.ts +1 -1
  33. package/src/test-utils/run-mcp-authorization.ts +7 -4
  34. package/src/test-utils/setup-mcp-home.ts +24 -13
  35. package/src/test-utils/setup-mcp-http.ts +15 -7
  36. package/src/test-utils/setup-temp-dir.ts +7 -6
  37. package/src/test-utils/start-cut-proxy.ts +6 -2
  38. package/src/test-utils/start-daemon-process.ts +25 -24
  39. package/src/test-utils/start-git-http-server.ts +9 -3
  40. package/src/test-utils/start-mcp-stdio.ts +17 -22
  41. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  42. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  43. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  44. package/src/test-utils/start-stub-impd-info.ts +10 -4
  45. package/src/test-utils/start-stub-impd.ts +8 -3
  46. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  47. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  48. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  49. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  50. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  51. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  52. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  53. package/src/test-utils/start-stub-silent-server.ts +10 -5
  54. package/src/test-utils/start-stub-source-daemon.ts +5 -3
  55. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  56. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  57. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  58. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  59. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  60. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  61. package/src/test-utils/start-test-daemon.ts +20 -6
  62. package/src/test-utils/start-tui-harness.ts +15 -8
  63. 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: Partial<MessageRecord> = {}): MessageRecord {
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. Disposal removes the directory; hold the result with
26
- * `await using`.
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
- await using stack = new AsyncDisposableStack();
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]: () => owned.disposeAsync(),
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 builds, one per call.
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
- * Builds an in-process stand-in for impd behind the imp port, calling impd
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 buildStubImpPort(principal = 'token:atc'): StubImpPort {
1628
- return new StubImpPort(principal);
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 { buildStubImpPort } from './build-stub-imp-port';
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 buildStubImpPort>, 'collectImpNames'>>,
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 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
+ * 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
  }