@zgeoff/atc 3.3.1 → 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.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +30 -17
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- 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
|
+
}
|