@zgeoff/atc 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +62 -13
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A peer socket for tests that drive a daemon connection without a socket:
3
+ * while accepting, it takes every byte at once, as a peer with room does,
4
+ * and keeps what the daemon wrote; while not, it takes none and returns 0,
5
+ * as a socket whose kernel buffer is full does, so backpressure is a switch
6
+ * instead of a buffer race. It starts accepting, and `setAccepting` flips
7
+ * it. It ignores the end of the connection. `collectFrames`
8
+ * parses each complete line written so far as one protocol frame, in order.
9
+ * `waitForAnswer` resolves with the frame answering the request id once the
10
+ * daemon has written it, that id left out, so two answers compare whole.
11
+ */
12
+ export function buildStubPeerSocket() {
13
+ const decoder = new TextDecoder();
14
+ const waiters = new Map<number, PromiseWithResolvers<Record<string, unknown>>>();
15
+
16
+ let accepting = true;
17
+ let written = '';
18
+
19
+ const collectFrames = (): Record<string, unknown>[] =>
20
+ written
21
+ .split('\n')
22
+ .slice(0, -1)
23
+ .map((line): Record<string, unknown> => toFrame(JSON.parse(line)));
24
+
25
+ return {
26
+ socket: {
27
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a readonly view cannot satisfy the writer contract; the stub never mutates chunks
28
+ write: (data: Uint8Array): number => {
29
+ if (!accepting) {
30
+ return 0;
31
+ }
32
+
33
+ written += decoder.decode(data, { stream: true });
34
+
35
+ // Frames are parsed only for a pending wait, so a stray line fails
36
+ // the read that looks at it rather than the daemon's write.
37
+ for (const frame of waiters.size === 0 ? [] : collectFrames()) {
38
+ const id = frame['id'];
39
+ const waiter = typeof id === 'number' ? waiters.get(id) : undefined;
40
+
41
+ if (waiter !== undefined) {
42
+ waiters.delete(Number(id));
43
+ waiter.resolve(buildAnswer(frame));
44
+ }
45
+ }
46
+
47
+ return data.length;
48
+ },
49
+ end: () => {},
50
+ },
51
+ setAccepting: (value: boolean) => {
52
+ accepting = value;
53
+ },
54
+ collectFrames,
55
+ waitForAnswer: (id: number): Promise<Record<string, unknown>> => {
56
+ const found = collectFrames().find((frame) => frame['id'] === id);
57
+
58
+ if (found !== undefined) {
59
+ return Promise.resolve(buildAnswer(found));
60
+ }
61
+
62
+ const waiter = Promise.withResolvers<Record<string, unknown>>();
63
+
64
+ waiters.set(id, waiter);
65
+
66
+ return waiter.promise;
67
+ },
68
+ };
69
+ }
70
+
71
+ function toFrame(value: unknown): Record<string, unknown> {
72
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
73
+ throw new TypeError(`a protocol frame is a JSON object, not ${JSON.stringify(value)}`);
74
+ }
75
+
76
+ return Object.fromEntries(Object.entries(value));
77
+ }
78
+
79
+ function buildAnswer(frame: Readonly<Record<string, unknown>>): Record<string, unknown> {
80
+ return Object.fromEntries(Object.entries(frame).filter(([key]) => key !== 'id'));
81
+ }
@@ -0,0 +1,36 @@
1
+ import type { DroppedAnswer } from '../client/spawn-picker';
2
+
3
+ /**
4
+ * The client a spawn picker runs inside, reduced to the reactions it
5
+ * records: `counts` holds how many times the picker asked for a status
6
+ * draw, returned to the screen the flow came from, attached a session, and
7
+ * dropped an answer, and `dropped` holds the kind of each dropped answer
8
+ * in order. `countReactions` returns the sum of every count, for a daemon
9
+ * stand-in that waits on the picker to react. Attaching resolves at once.
10
+ */
11
+ export function buildStubPickerHost() {
12
+ const counts = { renders: 0, exits: 0, attached: 0, drops: 0 };
13
+ const dropped: DroppedAnswer[] = [];
14
+
15
+ return {
16
+ counts,
17
+ dropped,
18
+ countReactions: (): number => counts.renders + counts.exits + counts.attached + counts.drops,
19
+ scheduleStatus: (): void => {
20
+ counts.renders += 1;
21
+ },
22
+ toBase: (): void => {
23
+ counts.exits += 1;
24
+ },
25
+ attach: (): Promise<void> => {
26
+ counts.attached += 1;
27
+
28
+ return Promise.resolve();
29
+ },
30
+ onDropAnswer: (kind: DroppedAnswer): void => {
31
+ counts.drops += 1;
32
+
33
+ dropped.push(kind);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,29 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import { buildStubBrokeredAgentAdapter } from './build-stub-brokered-agent-adapter';
3
+
4
+ interface StubProxiedAgentAdapterConfig {
5
+ readonly id: string;
6
+ }
7
+
8
+ /**
9
+ * An agent adapter for daemon tests that takes the GLM credential from
10
+ * impd's broker, as the brokered stand-in does, and that always starts
11
+ * behind the broker, but whose guest spawn plans a proxy variable of its
12
+ * own: `https_proxy` set to http://proxy.example:3128, beside a `sleep 30`.
13
+ * Every other member is the brokered stand-in's.
14
+ */
15
+ export function buildStubProxiedAgentAdapter(config: StubProxiedAgentAdapterConfig): AgentAdapter {
16
+ return {
17
+ ...buildStubBrokeredAgentAdapter({
18
+ id: config.id,
19
+ brokerRequired: true,
20
+ isSelected: () => true,
21
+ }),
22
+ planGuestSpawn: () => ({
23
+ bin: 'sleep',
24
+ args: ['30'],
25
+ files: {},
26
+ env: { https_proxy: 'http://proxy.example:3128' },
27
+ }),
28
+ };
29
+ }
@@ -0,0 +1,53 @@
1
+ import type {
2
+ ExecutionCapabilities,
3
+ ExecutionProvider,
4
+ HarnessSpec,
5
+ } from '../daemon/execution-provider';
6
+ import { LocalPTYProvider } from '../daemon/local-pty-provider';
7
+
8
+ interface StubPTYProviderConfig {
9
+ // The provider kind the stand-in reports, such as the kind a target's
10
+ // config selects; the local provider's own kind when absent.
11
+ readonly kind?: string;
12
+
13
+ // Capabilities that differ from the local provider's.
14
+ readonly capabilities?: Partial<ExecutionCapabilities>;
15
+
16
+ // Called with each harness spec before the harness starts.
17
+ readonly onSpawn?: (spec: HarnessSpec) => void;
18
+ readonly prepareHost?: ExecutionProvider['prepareHost'];
19
+ readonly suspendHost?: ExecutionProvider['suspendHost'];
20
+ readonly destroyHost?: ExecutionProvider['destroyHost'];
21
+ }
22
+
23
+ /**
24
+ * An execution provider for daemon tests that runs every harness on a real
25
+ * local pseudo-terminal, as the `local-pty` provider does, under whatever
26
+ * kind and capabilities the test gives it, so a target of any kind starts
27
+ * a real process. Each spawn is reported to `onSpawn` first. Host
28
+ * readying, sleeping, and destroying take the test's own operations when
29
+ * given, and the local provider's otherwise: readying always succeeds, and
30
+ * a sleep or a destroy rejects.
31
+ */
32
+ export function buildStubPTYProvider(config: StubPTYProviderConfig = {}): ExecutionProvider {
33
+ const local = new LocalPTYProvider();
34
+
35
+ const onSpawn = config.onSpawn ?? (() => {});
36
+
37
+ return {
38
+ kind: config.kind ?? local.kind,
39
+ remote: false,
40
+ capabilities: { ...local.capabilities, ...config.capabilities },
41
+ prepareHost: config.prepareHost ?? local.prepareHost,
42
+ spawnHarness: (spec) => {
43
+ onSpawn(spec);
44
+
45
+ return local.spawnHarness(spec);
46
+ },
47
+ transferArchive: local.transferArchive,
48
+ runCommand: local.runCommand,
49
+ suspendHost: config.suspendHost ?? local.suspendHost,
50
+ destroyHost: config.destroyHost ?? local.destroyHost,
51
+ dispose: local.dispose,
52
+ };
53
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The script of a stand-in `atc` that records each run: it appends
3
+ * `args:<arguments>`, `session:<$ATC_SESSION_ID>`, and `stdin:<what it
4
+ * read>` to the log, each on its own line, and exits 0.
5
+ */
6
+ export function buildStubRecordingATC(log: string): string {
7
+ return `#!/bin/sh
8
+ { printf 'args:%s\\n' "$*"; printf 'session:%s\\n' "$ATC_SESSION_ID"; printf 'stdin:'; cat; printf '\\n'; } >> '${log}'
9
+ `;
10
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The script of a stand-in `gh` that is signed in. It appends each command
3
+ * line it runs to `$HOME/gh-argv`. `gh config …` prints `https`. A
4
+ * repository listing of the signed-in account, `gh repo list --limit …`,
5
+ * prints one public repository, `me/dots`, and a listing of an owner,
6
+ * `gh repo list <owner> --limit …`, prints one private repository,
7
+ * `acme/app`, each in the JSON shape `gh repo list` prints.
8
+ */
9
+ export function buildStubSignedInGH(): string {
10
+ return `#!/bin/sh
11
+ printf '%s\\n' "$*" >> "$HOME/gh-argv"
12
+ case "$1 $3" in
13
+ "config "*) echo https ;;
14
+ "repo --limit") echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
15
+ *) echo '[{"nameWithOwner":"acme/app","description":"","isPrivate":true,"url":"https://github.com/acme/app","sshUrl":"git@github.com:acme/app.git"}]' ;;
16
+ esac
17
+ `;
18
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The script of a stand-in `gh` that is signed out: every command prints
3
+ * gh's own sign-in hint to stderr and exits 4, as the real one does before
4
+ * `gh auth login`. The hint is gh 2.99.0's text, byte for byte.
5
+ */
6
+ export function buildStubSignedOutGH(): string {
7
+ return "#!/bin/sh\nprintf '%s\\n%s\\n' 'To get started with GitHub CLI, please run: gh auth login' 'Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.' >&2\nexit 4\n";
8
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExecutionProvider, HarnessHandle } from '../daemon/execution-provider';
2
+ import { LocalPTYProvider } from '../daemon/local-pty-provider';
3
+
4
+ /**
5
+ * An execution provider of kind `no-forced-kill` that runs every harness on
6
+ * this machine through the local terminal provider, but whose harnesses
7
+ * have no forced kill to send, as a remote provider's have none: a harness
8
+ * that ignores its kill keeps running.
9
+ */
10
+ export function buildStubSoftKillProvider(): ExecutionProvider {
11
+ const local = new LocalPTYProvider();
12
+
13
+ return {
14
+ kind: 'no-forced-kill',
15
+ remote: false,
16
+ capabilities: local.capabilities,
17
+ prepareHost: local.prepareHost,
18
+ spawnHarness: (spec): HarnessHandle => {
19
+ const { killForced: _killForced, ...handle } = local.spawnHarness(spec);
20
+
21
+ return handle;
22
+ },
23
+ transferArchive: local.transferArchive,
24
+ runCommand: local.runCommand,
25
+ suspendHost: local.suspendHost,
26
+ destroyHost: local.destroyHost,
27
+ dispose: local.dispose,
28
+ };
29
+ }
@@ -0,0 +1,58 @@
1
+ import type { ExecutionTarget } from '../daemon/build-execution-targets';
2
+ import { buildTargetIdentity } from '../daemon/build-target-identity';
3
+ import type { TargetConfig } from '../shared/collect-targets';
4
+ import { buildStubPTYProvider } from './build-stub-pty-provider';
5
+
6
+ // What a target's provider does in place of the local one's: the
7
+ // capabilities it adds and the host preparation, sleep, and destroy it runs.
8
+ type HostOverride = Omit<
9
+ NonNullable<Parameters<typeof buildStubPTYProvider>[0]>,
10
+ 'kind' | 'onSpawn'
11
+ >;
12
+
13
+ interface StubTargetsConfig {
14
+ // Each spawn appends its target's id here, in order.
15
+ readonly spawned: string[];
16
+
17
+ // Host operations of their own for the targets they name.
18
+ readonly hosts?: Readonly<Record<string, HostOverride>>;
19
+ }
20
+
21
+ /**
22
+ * The daemon's execution targets for configured target entries, as the
23
+ * config load or the targets section parse gives them, in their order:
24
+ * each `local-pty` entry runs its harnesses on a real local
25
+ * pseudo-terminal through a stand-in provider that appends the entry's id
26
+ * to `spawned` on every spawn and takes the host operations `hosts` gives
27
+ * it, and an entry of any other kind has no provider, as a provider this
28
+ * daemon lacks leaves it. Each target's identity is the real one for its
29
+ * kind and options.
30
+ */
31
+ export function buildStubTargets(
32
+ targets: readonly TargetConfig[],
33
+
34
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the stand-in appends to the caller's spawn list
35
+ config: StubTargetsConfig,
36
+ ): ExecutionTarget[] {
37
+ const hosts: Readonly<Record<string, HostOverride>> = { ...config.hosts };
38
+
39
+ const providers = new Map(
40
+ targets.map((target) => [
41
+ target.id,
42
+ buildStubPTYProvider({
43
+ onSpawn: () => {
44
+ config.spawned.push(target.id);
45
+ },
46
+ ...hosts[target.id],
47
+ }),
48
+ ]),
49
+ );
50
+
51
+ return targets.map((target) => ({
52
+ id: target.id,
53
+ kind: target.provider,
54
+ options: target.options,
55
+ identity: buildTargetIdentity(target.provider, target.options),
56
+ provider: target.provider === 'local-pty' ? (providers.get(target.id) ?? null) : null,
57
+ }));
58
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A terminal that keeps every chunk written to it, in order, and draws
3
+ * nothing. `getText` returns everything written. `mark` returns a position
4
+ * at the end of what is written so far, and `getTextSince` returns only
5
+ * what was written after that position, so a test reads only the output of
6
+ * the action it checks.
7
+ */
8
+ export function buildStubTerminal() {
9
+ const chunks: string[] = [];
10
+
11
+ return {
12
+ write: (chunk: string): void => {
13
+ chunks.push(chunk);
14
+ },
15
+ getText: (): string => chunks.join(''),
16
+ mark: (): number => chunks.length,
17
+ getTextSince: (mark: number): string => chunks.slice(mark).join(''),
18
+ };
19
+ }
@@ -0,0 +1,48 @@
1
+ // One timer the scheduler started: its delay, what it runs, and whether it
2
+ // is still pending, has run, or was cancelled.
3
+ interface StubTimer {
4
+ readonly ms: number;
5
+ readonly onTimeout: () => void;
6
+ state: 'pending' | 'ran' | 'cancelled';
7
+ }
8
+
9
+ /**
10
+ * A timeout scheduler for code that takes one in place of `setTimeout`: no
11
+ * timer ever runs on its own. `schedule` records the timer and returns what
12
+ * cancels it, as a real scheduler does; `runTimer` runs the oldest pending
13
+ * timer of the given delay and throws when none is pending, so a test both
14
+ * proves the code asked for that delay and moves time past it.
15
+ * `collectDelays` returns the delay of every timer in the order the code
16
+ * started them, and `collectPendingDelays` those neither run nor cancelled.
17
+ */
18
+ export function buildStubTimeoutScheduler() {
19
+ const timers: StubTimer[] = [];
20
+
21
+ return {
22
+ schedule: (onTimeout: () => void, ms: number): (() => void) => {
23
+ const timer: StubTimer = { ms, onTimeout, state: 'pending' };
24
+
25
+ timers.push(timer);
26
+
27
+ return () => {
28
+ timer.state = timer.state === 'pending' ? 'cancelled' : timer.state;
29
+ };
30
+ },
31
+ runTimer: (ms: number): void => {
32
+ const timer = timers.find(
33
+ (candidate) => candidate.state === 'pending' && candidate.ms === ms,
34
+ );
35
+
36
+ if (timer === undefined) {
37
+ throw new Error(`no pending timer of ${ms} ms`);
38
+ }
39
+
40
+ timer.state = 'ran';
41
+
42
+ timer.onTimeout();
43
+ },
44
+ collectDelays: (): number[] => timers.map((timer) => timer.ms),
45
+ collectPendingDelays: (): number[] =>
46
+ timers.filter((timer) => timer.state === 'pending').map((timer) => timer.ms),
47
+ };
48
+ }
@@ -0,0 +1,47 @@
1
+ import { join } from 'node:path';
2
+
3
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
4
+
5
+ /**
6
+ * The script of a stand-in `claude` for the TUI suites. It prints
7
+ * `FAKE_CLAUDE_UP args: ` and its arguments at start and again on every
8
+ * SIGWINCH, as the real one repaints on a resize. It reports `SessionStart`
9
+ * through the real reporter with session id `fake-1` and transcript
10
+ * `$HOME/fake-transcript.jsonl`, then a permission `Notification`, and
11
+ * echoes each line it reads as `GOT:<line>` for five minutes. Scenario files
12
+ * in `$HOME` change it: `fake-claude-events.jsonl` replaces the
13
+ * notification with its hook lines, and a run with `--resume` among its
14
+ * arguments touches `fake-claude-resume-held` and waits, before it prints
15
+ * or reports anything, while `fake-claude-hold-resume` exists.
16
+ */
17
+ export function buildStubTUIClaude(): string {
18
+ const report = `"${process.execPath}" "${CLI_PATH}" hook-report`;
19
+
20
+ return `#!/usr/bin/env bash
21
+ ARGS="$*"
22
+ case "$ARGS" in *--resume*)
23
+ if [ -f "$HOME/fake-claude-hold-resume" ]; then
24
+ touch "$HOME/fake-claude-resume-held"
25
+ while [ -f "$HOME/fake-claude-hold-resume" ]; do sleep 0.05; done
26
+ fi ;;
27
+ esac
28
+ paint() { echo "FAKE_CLAUDE_UP args: $ARGS"; }
29
+ trap paint WINCH
30
+ paint
31
+ printf '{"hook_event_name":"SessionStart","session_id":"fake-1","transcript_path":"'"$HOME"'/fake-transcript.jsonl"}' | ${report}
32
+ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
33
+ while IFS= read -r ev; do
34
+ [ -n "$ev" ] || continue
35
+ sleep 0.3
36
+ printf '%s' "$ev" | ${report}
37
+ done < "$HOME/fake-claude-events.jsonl"
38
+ else
39
+ sleep 0.3
40
+ printf '{"hook_event_name":"Notification","session_id":"fake-1","message":"needs permission"}' | ${report}
41
+ fi
42
+ # bash 3.2 read -t takes whole seconds; a fraction times out immediately
43
+ for _ in $(seq 1 300); do
44
+ if read -t 1 -r line; then echo "GOT:$line"; fi
45
+ done
46
+ `;
47
+ }
@@ -0,0 +1,53 @@
1
+ import { join } from 'node:path';
2
+
3
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
4
+
5
+ /**
6
+ * The script of a stand-in `grok` for the TUI suites. It prints
7
+ * `FAKE_GROK_UP args: ` and its arguments at start and again on every
8
+ * SIGWINCH, reports `session_start` through the real reporter with session
9
+ * id `fake-grok-1` and its working directory, then a `permission_prompt`
10
+ * notification, prints `FAKE_GROK_HOOKS_DONE`, and echoes each line it
11
+ * reads as `GOT:<line>` for five minutes. Scenario files in `$HOME` change
12
+ * it: `fake-grok-events.jsonl` replaces the notification with its hook
13
+ * lines, `fake-grok-hold-start` skips every report, and while
14
+ * `fake-grok-defer-start` exists it touches `fake-grok-start-deferred` and
15
+ * waits before it reports.
16
+ */
17
+ export function buildStubTUIGrok(): string {
18
+ const report = `"${process.execPath}" "${CLI_PATH}" hook-report`;
19
+
20
+ return `#!/usr/bin/env bash
21
+ ARGS="$*"
22
+ paint() { echo "FAKE_GROK_UP args: $ARGS"; }
23
+ trap paint WINCH
24
+ paint
25
+ idle() {
26
+ for _ in $(seq 1 300); do
27
+ if read -t 1 -r line; then echo "GOT:$line"; fi
28
+ done
29
+ }
30
+ if [ -f "$HOME/fake-grok-hold-start" ]; then
31
+ idle
32
+ exit 0
33
+ fi
34
+ if [ -f "$HOME/fake-grok-defer-start" ]; then
35
+ touch "$HOME/fake-grok-start-deferred"
36
+ while [ -f "$HOME/fake-grok-defer-start" ]; do sleep 0.05; done
37
+ fi
38
+ printf '{"hookEventName":"session_start","sessionId":"fake-grok-1","cwd":"%s"}' "$PWD" | ${report}
39
+ if [ -f "$HOME/fake-grok-events.jsonl" ]; then
40
+ sleep 0.3
41
+ while IFS= read -r ev; do
42
+ [ -n "$ev" ] || continue
43
+ printf '%s' "$ev" | ${report}
44
+ sleep 0.2
45
+ done < "$HOME/fake-grok-events.jsonl"
46
+ else
47
+ sleep 0.3
48
+ printf '{"hookEventName":"notification","sessionId":"fake-grok-1","notificationType":"permission_prompt","message":"allow edit?"}' | ${report}
49
+ fi
50
+ echo "FAKE_GROK_HOOKS_DONE"
51
+ idle
52
+ `;
53
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A clock for a polling wait that moves only when the wait waits it out:
3
+ * `now` starts at zero, and `wait` records the milliseconds it was asked to
4
+ * wait in `waits`, moves `now` forward by them, and resolves at once. A
5
+ * deadline then passes after a known number of attempts, with no real time
6
+ * spent.
7
+ */
8
+ export function buildStubWaitClock() {
9
+ const waits: number[] = [];
10
+ let elapsed = 0;
11
+
12
+ return {
13
+ waits,
14
+ now: () => elapsed,
15
+ wait: (ms: number): Promise<void> => {
16
+ waits.push(ms);
17
+
18
+ elapsed += ms;
19
+
20
+ return Promise.resolve();
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,37 @@
1
+ import { buildTargetIdentity } from '../daemon/build-target-identity';
2
+ import type { DaemonOptions } from '../daemon/daemon';
3
+ import type { ExecutionProvider } from '../daemon/execution-provider';
4
+ import { collectTargets } from '../shared/collect-targets';
5
+
6
+ interface RawTargetConfig {
7
+ // The raw `targets` key of a config file.
8
+ readonly targets?: unknown;
9
+
10
+ // The raw `defaultTarget` key of a config file.
11
+ readonly defaultTarget?: unknown;
12
+ }
13
+
14
+ /**
15
+ * Turns the raw target keys of a config file into daemon options through
16
+ * the real parse. Each parsed target takes the provider that the factory
17
+ * for its kind builds for its id, or no provider when no factory serves its
18
+ * kind, and every parse error passes through as a target config error.
19
+ */
20
+ export function buildTargetOptionsFromConfig(
21
+ raw: RawTargetConfig,
22
+ factories: ReadonlyMap<string, (id: string) => ExecutionProvider>,
23
+ ): Required<Pick<DaemonOptions, 'targets' | 'defaultTarget' | 'targetErrors'>> {
24
+ const parsed = collectTargets(raw.targets, raw.defaultTarget);
25
+
26
+ return {
27
+ targets: parsed.targets.map((target) => ({
28
+ id: target.id,
29
+ kind: target.provider,
30
+ options: target.options,
31
+ identity: buildTargetIdentity(target.provider, target.options),
32
+ provider: factories.get(target.provider)?.(target.id) ?? null,
33
+ })),
34
+ defaultTarget: parsed.defaultTarget,
35
+ targetErrors: parsed.errors,
36
+ };
37
+ }
@@ -0,0 +1,75 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export interface TreeProcess {
5
+ readonly pid: number;
6
+ readonly argv: readonly string[];
7
+ readonly env: Readonly<Record<string, string>>;
8
+ }
9
+
10
+ /**
11
+ * Collects the argv and environment of every live process descended from
12
+ * the root process, read from `/proc`, so a test can check what a child
13
+ * was started with while it still runs. The root itself is left out. A
14
+ * process that exits during the scan is left out too. Linux only, since
15
+ * only Linux has `/proc`.
16
+ */
17
+ export async function collectProcessTree(rootPID: number): Promise<TreeProcess[]> {
18
+ const entries = await readdir('/proc');
19
+
20
+ const pids = entries.filter((entry) => /^\d+$/u.test(entry));
21
+
22
+ const parents = new Map<string, string>();
23
+
24
+ for (const pid of pids) {
25
+ const stat = await readFile(join('/proc', pid, 'stat'), 'utf8').catch(() => '');
26
+
27
+ // The fields after the parenthesised command name start with the state,
28
+ // then the parent pid.
29
+ parents.set(pid, stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1] ?? '');
30
+ }
31
+
32
+ const root = String(rootPID);
33
+ const tree: TreeProcess[] = [];
34
+
35
+ for (const pid of pids) {
36
+ if (!isDescendant(parents, pid, root)) {
37
+ continue;
38
+ }
39
+
40
+ const cmdline = await readFile(join('/proc', pid, 'cmdline'), 'utf8').catch(() => null);
41
+ const environ = await readFile(join('/proc', pid, 'environ'), 'utf8').catch(() => null);
42
+
43
+ if (cmdline === null || environ === null) {
44
+ continue;
45
+ }
46
+
47
+ tree.push({
48
+ pid: Number(pid),
49
+ argv: splitNullTerminated(cmdline),
50
+ env: Object.fromEntries(
51
+ splitNullTerminated(environ).map((entry) => {
52
+ const equals = entry.indexOf('=');
53
+
54
+ return [entry.slice(0, equals), entry.slice(equals + 1)];
55
+ }),
56
+ ),
57
+ });
58
+ }
59
+
60
+ return tree;
61
+ }
62
+
63
+ function isDescendant(parents: ReadonlyMap<string, string>, pid: string, root: string): boolean {
64
+ let ancestor = parents.get(pid);
65
+
66
+ while (ancestor !== undefined && ancestor !== root) {
67
+ ancestor = parents.get(ancestor);
68
+ }
69
+
70
+ return ancestor === root;
71
+ }
72
+
73
+ function splitNullTerminated(text: string): string[] {
74
+ return text.split('\0').filter((entry) => entry !== '');
75
+ }