@velum-labs/routekit-harness-core 0.9.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.
@@ -0,0 +1,259 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { z } from "zod";
7
+ import { DriverRegistry, EventLog, HarnessError, PendingRequests, asHarnessError, createCachedHarnessDriver, createStreamJsonStepEmitter, createTrackedTmpDir, decideApproval, parseStreamJsonTrajectory, probeCliVersion, readCachedStatus, releaseTrackedTmpDir, resolveDriverEnv, statusSkipReason, streamJsonResultContentText, sweepTrackedTmpDirs, writeCachedStatus } from "../index.js";
8
+ import { createMockDriver, driverContractSuite } from "../testing/index.js";
9
+ // The mock driver is the reference implementation: it must pass the same
10
+ // contract suite every real driver is held against.
11
+ driverContractSuite({
12
+ name: "mock driver",
13
+ createInstance: async () => {
14
+ const driver = createMockDriver();
15
+ return driver.createInstance(driver.configSchema.parse({}));
16
+ },
17
+ startOptions: () => ({ cwd: process.cwd() }),
18
+ supportsResume: true
19
+ });
20
+ test("shared stream JSON primitives normalize incremental and buffered events", () => {
21
+ const stepsForEvent = (event) => event.type === "message" && typeof event.text === "string"
22
+ ? [{ kind: "message", text: event.text }]
23
+ : [];
24
+ const resultStep = (result) => ({ kind: "result", text: result });
25
+ const emitted = [];
26
+ const emit = createStreamJsonStepEmitter({ stepsForEvent, resultStep, onStep: (step) => emitted.push(step) });
27
+ emit('{"type":"message","text":"hello"}');
28
+ emit('{"type":"result","result":"done"}');
29
+ assert.deepEqual(emitted, [
30
+ { index: 0, kind: "message", text: "hello" },
31
+ { index: 1, kind: "result", text: "done" }
32
+ ]);
33
+ const parsed = parseStreamJsonTrajectory({
34
+ stdout: '{"type":"message","text":"hello"}\n' +
35
+ '{"type":"result","result":"done","is_error":false}\n',
36
+ stepsForEvent,
37
+ resultStep
38
+ });
39
+ assert.equal(parsed.finalOutput, "done");
40
+ assert.equal(parsed.sawResult, true);
41
+ assert.equal(parsed.isError, false);
42
+ assert.equal(streamJsonResultContentText([{ type: "text", text: "a" }, { type: "text", text: "b" }]), "ab");
43
+ });
44
+ test("registry decodes config exactly once and classifies failures", async () => {
45
+ const registry = new DriverRegistry().register(createMockDriver());
46
+ const instance = await registry.createInstance("generic", { replies: ["hi"] });
47
+ assert.equal(instance.kind, "generic");
48
+ await instance.dispose();
49
+ await assert.rejects(registry.createInstance("generic", { replies: "not-an-array" }), (error) => error.code === "invalid_config");
50
+ await assert.rejects(registry.createInstance("codex", {}), (error) => error.code === "invalid_config");
51
+ assert.throws(() => registry.register(createMockDriver()), /already registered/);
52
+ });
53
+ test("error taxonomy derives retryability and category", () => {
54
+ const timeout = new HarnessError("timeout", "deadline exceeded");
55
+ assert.equal(timeout.retryable, true);
56
+ assert.equal(timeout.category, "transient");
57
+ const auth = new HarnessError("not_authenticated", "run codex login");
58
+ assert.equal(auth.retryable, false);
59
+ assert.equal(auth.category, "auth_permanent");
60
+ const quota = new HarnessError("provider_error", "out of credits", {
61
+ category: "quota_exhausted"
62
+ });
63
+ assert.equal(quota.retryable, true);
64
+ const enoent = asHarnessError(Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT" }));
65
+ assert.equal(enoent.code, "not_installed");
66
+ });
67
+ test("approvals: policy verdicts and teardown settlement", async () => {
68
+ assert.equal(decideApproval({ autoApprove: "all" }, "exec_command_approval"), "accept");
69
+ assert.equal(decideApproval({ autoApprove: "all" }, "tool_user_input"), undefined);
70
+ assert.equal(decideApproval({ autoApprove: "edits" }, "file_change_approval"), "accept");
71
+ assert.equal(decideApproval({ autoApprove: "edits" }, "exec_command_approval"), undefined);
72
+ assert.equal(decideApproval({ autoApprove: "none" }, "file_change_approval"), undefined);
73
+ const pending = new PendingRequests();
74
+ const first = pending.open({ requestType: "exec_command_approval", detail: "rm -rf" });
75
+ const second = pending.open({ requestType: "tool_user_input" });
76
+ assert.equal(pending.resolve(first.requestId, "decline"), true);
77
+ assert.equal(await first.decision, "decline");
78
+ assert.equal(pending.resolve(first.requestId, "accept"), false);
79
+ assert.equal(pending.settleAll("cancel"), 1);
80
+ assert.equal(await second.decision, "cancel");
81
+ });
82
+ test("status cache round-trips and rejects identity mismatches", () => {
83
+ const dir = mkdtempSync(join(tmpdir(), "harness-status-"));
84
+ try {
85
+ const status = {
86
+ kind: "codex",
87
+ installed: true,
88
+ command: "codex",
89
+ version: "0.99.0",
90
+ auth: { status: "authenticated" },
91
+ checkedAt: new Date().toISOString()
92
+ };
93
+ writeCachedStatus(status, dir);
94
+ assert.deepEqual(readCachedStatus("codex", dir), status);
95
+ // The payload's kind, not the filename, is the routing key.
96
+ assert.equal(readCachedStatus("cursor", dir), undefined);
97
+ assert.equal(statusSkipReason(status), undefined);
98
+ assert.match(statusSkipReason({ ...status, auth: { status: "unauthenticated" } }) ?? "", /not logged in/);
99
+ assert.match(statusSkipReason({ ...status, installed: false }) ?? "", /not installed/);
100
+ }
101
+ finally {
102
+ rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+ test("driver env resolution honors an explicit context without merging", () => {
106
+ const env = { PATH: "/custom/bin", ROUTEKIT_TEST_TOKEN: "secret" };
107
+ assert.equal(resolveDriverEnv({ env }), env);
108
+ assert.equal(resolveDriverEnv(undefined), process.env);
109
+ });
110
+ test("CLI version probing normalizes success and failures", async () => {
111
+ const probed = await probeCliVersion({
112
+ kind: "generic",
113
+ command: process.execPath,
114
+ cliName: "node",
115
+ args: ["-e", 'process.stdout.write("tool 1.2.3\\n")'],
116
+ env: {},
117
+ auth: { status: "unknown" },
118
+ notInstalledMessage: "node missing"
119
+ });
120
+ assert.equal(probed.installed, true);
121
+ assert.equal(probed.version, "1.2.3");
122
+ const failed = await probeCliVersion({
123
+ kind: "generic",
124
+ command: process.execPath,
125
+ cliName: "node",
126
+ args: ["-e", 'process.stderr.write("bad version probe\\n"); process.exit(7)'],
127
+ env: {},
128
+ auth: { status: "authenticated" },
129
+ failureAuth: { status: "unauthenticated" },
130
+ notInstalledMessage: "node missing"
131
+ });
132
+ assert.equal(failed.installed, false);
133
+ assert.equal(failed.auth.status, "unauthenticated");
134
+ assert.equal(failed.probeError, "bad version probe");
135
+ const missing = await probeCliVersion({
136
+ kind: "generic",
137
+ command: join(tmpdir(), `missing-cli-${process.pid}`),
138
+ cliName: "missing",
139
+ env: {},
140
+ auth: { status: "authenticated" },
141
+ notInstalledAuth: { status: "unknown" },
142
+ notInstalledMessage: "missing CLI is not installed"
143
+ });
144
+ assert.equal(missing.installed, false);
145
+ assert.equal(missing.auth.status, "unknown");
146
+ assert.equal(missing.probeError, "missing CLI is not installed");
147
+ });
148
+ test("cached driver construction reuses status written by probe", async () => {
149
+ const cacheDir = mkdtempSync(join(tmpdir(), "harness-driver-cache-"));
150
+ let probeCount = 0;
151
+ const status = {
152
+ kind: "generic",
153
+ installed: true,
154
+ command: "tool",
155
+ version: "1.0.0",
156
+ auth: { status: "authenticated" },
157
+ checkedAt: new Date().toISOString()
158
+ };
159
+ const instance = {
160
+ kind: "generic",
161
+ status: () => status,
162
+ startSession: async () => {
163
+ throw new Error("not used");
164
+ },
165
+ dispose: async () => { }
166
+ };
167
+ let instanceStatus;
168
+ const driver = createCachedHarnessDriver({
169
+ kind: "generic",
170
+ configSchema: z.object({ command: z.string().default("tool") }),
171
+ probeConfig: () => ({ command: "tool" }),
172
+ probeStatus: async () => {
173
+ probeCount += 1;
174
+ return status;
175
+ },
176
+ createInstance: (_config, _context, cachedStatus) => {
177
+ instanceStatus = cachedStatus;
178
+ return instance;
179
+ }
180
+ });
181
+ try {
182
+ await driver.probe({ statusCacheDir: cacheDir });
183
+ const created = await driver.createInstance(driver.configSchema.parse({}), { statusCacheDir: cacheDir });
184
+ assert.equal(created, instance);
185
+ assert.deepEqual(instanceStatus, status);
186
+ assert.equal(probeCount, 1);
187
+ }
188
+ finally {
189
+ rmSync(cacheDir, { recursive: true, force: true });
190
+ }
191
+ });
192
+ test("mock session surfaces approvals under autoApprove none and resolves them", async () => {
193
+ const driver = createMockDriver();
194
+ const instance = await driver.createInstance(driver.configSchema.parse({ approvalDetail: "npm test", replies: ["done"] }));
195
+ const session = await instance.startSession({
196
+ cwd: process.cwd(),
197
+ approvalPolicy: { autoApprove: "none" }
198
+ });
199
+ const events = [];
200
+ for await (const event of session.sendTurn({ prompt: "run tests" })) {
201
+ events.push(event);
202
+ if (event.type === "request.opened") {
203
+ await session.respondToRequest(event.requestId, "accept");
204
+ }
205
+ }
206
+ const types = events.map((event) => event.type);
207
+ assert.deepEqual(types, [
208
+ "turn.started",
209
+ "request.opened",
210
+ "request.resolved",
211
+ "content.delta",
212
+ "turn.completed"
213
+ ]);
214
+ await instance.dispose();
215
+ });
216
+ test("EventLog writes NDJSON per session and summarizes raw payloads", () => {
217
+ const dir = mkdtempSync(join(tmpdir(), "harness-log-"));
218
+ try {
219
+ const log = new EventLog({ dir });
220
+ log.write({
221
+ kind: "codex",
222
+ sessionId: "sess/1",
223
+ at: new Date().toISOString(),
224
+ type: "content.delta",
225
+ stream: "assistant_text",
226
+ text: "hi",
227
+ raw: { source: "codex.exec.json", method: "item.completed", payload: { a: 1, b: 2 } }
228
+ });
229
+ const contents = readFileSync(join(dir, "sess_1.ndjson"), "utf8").trim();
230
+ const parsed = JSON.parse(contents);
231
+ assert.equal(parsed.type, "content.delta");
232
+ // The raw payload is summarized to a shape, never written whole.
233
+ assert.equal(parsed.raw.source, "codex.exec.json");
234
+ assert.equal(parsed.raw.payload.fields, 2);
235
+ assert.ok(parsed.raw.payload.bytes > 0);
236
+ }
237
+ finally {
238
+ rmSync(dir, { recursive: true, force: true });
239
+ }
240
+ });
241
+ test("tracked temp dirs are swept after a simulated crash", () => {
242
+ const manifestDir = mkdtempSync(join(tmpdir(), "harness-manifest-"));
243
+ const manifest = join(manifestDir, "tmp-manifest.json");
244
+ try {
245
+ const leaked = createTrackedTmpDir("harness-sweep-", manifest);
246
+ const kept = createTrackedTmpDir("harness-sweep-", manifest);
247
+ // A clean release drops one entry; the other is "leaked" by a crash.
248
+ releaseTrackedTmpDir(kept, manifest);
249
+ assert.equal(existsSync(kept), false);
250
+ assert.equal(existsSync(leaked), true);
251
+ const swept = sweepTrackedTmpDirs(manifest);
252
+ assert.deepEqual(swept, [leaked]);
253
+ assert.equal(existsSync(leaked), false);
254
+ assert.deepEqual(sweepTrackedTmpDirs(manifest), []);
255
+ }
256
+ finally {
257
+ rmSync(manifestDir, { recursive: true, force: true });
258
+ }
259
+ });
@@ -0,0 +1,22 @@
1
+ import type { HarnessInstance, StartSessionOptions } from "../contract.js";
2
+ export type DriverContractSuiteInput = {
3
+ /** Suite label, e.g. "codex driver". */
4
+ name: string;
5
+ /** Fresh instance per test; disposed by the suite. */
6
+ createInstance: () => Promise<HarnessInstance>;
7
+ /** Session options valid for this driver (cwd usually a temp repo). */
8
+ startOptions: () => StartSessionOptions;
9
+ prompt?: string;
10
+ /** Set when the driver supports native resume; enables the resume test. */
11
+ supportsResume?: boolean;
12
+ /** Upper bound for a scripted turn to settle (default 30s). */
13
+ turnTimeoutMs?: number;
14
+ /** Skip reason (e.g. CLI not installed locally); tests report skipped, never silently pass. */
15
+ skip?: string | false;
16
+ };
17
+ /**
18
+ * The invariants every harness driver must satisfy, executed as node:test
19
+ * tests. Drivers register their own scripted fixtures (fake CLI binaries or
20
+ * scripted transports) and hand the suite a fresh instance per test.
21
+ */
22
+ export declare function driverContractSuite(input: DriverContractSuiteInput): void;
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ async function collect(events, timeoutMs) {
4
+ const out = [];
5
+ const deadline = new Error(`turn did not settle within ${timeoutMs}ms`);
6
+ let timer;
7
+ const timeout = new Promise((_, reject) => {
8
+ timer = setTimeout(() => reject(deadline), timeoutMs);
9
+ });
10
+ try {
11
+ const iterator = events[Symbol.asyncIterator]();
12
+ for (;;) {
13
+ const next = await Promise.race([iterator.next(), timeout]);
14
+ if (next.done === true)
15
+ break;
16
+ out.push(next.value);
17
+ }
18
+ return out;
19
+ }
20
+ finally {
21
+ if (timer !== undefined)
22
+ clearTimeout(timer);
23
+ }
24
+ }
25
+ /**
26
+ * The invariants every harness driver must satisfy, executed as node:test
27
+ * tests. Drivers register their own scripted fixtures (fake CLI binaries or
28
+ * scripted transports) and hand the suite a fresh instance per test.
29
+ */
30
+ export function driverContractSuite(input) {
31
+ const timeoutMs = input.turnTimeoutMs ?? 30_000;
32
+ const prompt = input.prompt ?? "Reply with OK.";
33
+ const skip = input.skip ?? false;
34
+ test(`${input.name}: a scripted turn completes with canonical events`, { skip }, async () => {
35
+ const instance = await input.createInstance();
36
+ try {
37
+ const session = await instance.startSession(input.startOptions());
38
+ const events = await collect(session.sendTurn({ prompt }), timeoutMs);
39
+ const types = events.map((event) => event.type);
40
+ assert.ok(types.includes("turn.started"), `turn.started missing in ${types.join(",")}`);
41
+ const completed = events.find((event) => event.type === "turn.completed");
42
+ assert.ok(completed, `turn.completed missing in ${types.join(",")}`);
43
+ assert.equal(completed.endReason, "completed");
44
+ assert.ok(events.every((event) => event.sessionId === session.sessionId), "every event carries the session id");
45
+ await session.stop();
46
+ }
47
+ finally {
48
+ await instance.dispose();
49
+ }
50
+ });
51
+ test(`${input.name}: an aborted turn settles as aborted, never hangs`, { skip }, async () => {
52
+ const instance = await input.createInstance();
53
+ try {
54
+ const session = await instance.startSession(input.startOptions());
55
+ const events = await collect(session.sendTurn({ prompt, signal: AbortSignal.abort(new Error("straggler_abandoned")) }), timeoutMs);
56
+ const terminal = events.at(-1);
57
+ assert.ok(terminal, "an aborted turn still yields events");
58
+ assert.ok((terminal.type === "turn.completed" &&
59
+ (terminal.endReason === "aborted" || terminal.endReason === "interrupted")) ||
60
+ terminal.type === "turn.failed", `terminal event must record the abort, got ${terminal.type}`);
61
+ await session.stop();
62
+ }
63
+ finally {
64
+ await instance.dispose();
65
+ }
66
+ });
67
+ test(`${input.name}: stop settles pending approvals (nothing hangs)`, { skip }, async () => {
68
+ const instance = await input.createInstance();
69
+ try {
70
+ const session = await instance.startSession(input.startOptions());
71
+ // Consume the stream concurrently; stop() must let it terminate even
72
+ // if the driver opened approvals that were never answered.
73
+ const collecting = collect(session.sendTurn({ prompt }), timeoutMs);
74
+ await session.stop();
75
+ await collecting.catch(() => undefined);
76
+ }
77
+ finally {
78
+ await instance.dispose();
79
+ }
80
+ });
81
+ if (input.supportsResume === true) {
82
+ test(`${input.name}: resume cursor round-trips into a new session`, { skip }, async () => {
83
+ const instance = await input.createInstance();
84
+ try {
85
+ const first = await instance.startSession(input.startOptions());
86
+ await collect(first.sendTurn({ prompt }), timeoutMs);
87
+ const cursor = first.resumeCursor();
88
+ assert.ok(cursor, "driver advertises resume but produced no cursor");
89
+ await first.stop();
90
+ const resumed = await instance.startSession({ ...input.startOptions(), resume: cursor });
91
+ assert.equal(resumed.sessionId, first.sessionId, "resume restores the session identity");
92
+ await resumed.stop();
93
+ }
94
+ finally {
95
+ await instance.dispose();
96
+ }
97
+ });
98
+ }
99
+ }
@@ -0,0 +1,4 @@
1
+ export { createMockDriver, mockDriverConfigSchema } from "./mock-driver.js";
2
+ export type { MockDriverConfig } from "./mock-driver.js";
3
+ export { driverContractSuite } from "./contract-suite.js";
4
+ export type { DriverContractSuiteInput } from "./contract-suite.js";
@@ -0,0 +1,2 @@
1
+ export { createMockDriver, mockDriverConfigSchema } from "./mock-driver.js";
2
+ export { driverContractSuite } from "./contract-suite.js";
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import type { HarnessDriver } from "../contract.js";
3
+ export declare const mockDriverConfigSchema: z.ZodObject<{
4
+ replies: z.ZodDefault<z.ZodArray<z.ZodString>>;
5
+ approvalDetail: z.ZodOptional<z.ZodString>;
6
+ installed: z.ZodDefault<z.ZodBoolean>;
7
+ authenticated: z.ZodDefault<z.ZodBoolean>;
8
+ }, z.core.$strip>;
9
+ export type MockDriverConfig = z.infer<typeof mockDriverConfigSchema>;
10
+ /**
11
+ * A fully in-memory driver implementing the whole contract: scripted replies,
12
+ * approvals, resume cursors, abort handling. The fixture for registry and
13
+ * consumer tests, and the reference implementation drivers are held against.
14
+ */
15
+ export declare function createMockDriver(): HarnessDriver<MockDriverConfig>;
@@ -0,0 +1,154 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { HarnessError } from "../errors.js";
4
+ import { DEFAULT_AUTOMATION_APPROVAL_POLICY, PendingRequests, decideApproval } from "../approvals.js";
5
+ export const mockDriverConfigSchema = z.object({
6
+ /** Scripted assistant replies, consumed one per turn (last one repeats). */
7
+ replies: z.array(z.string()).default(["mock reply"]),
8
+ /** When set, every `sendTurn` opens one approval of this detail first. */
9
+ approvalDetail: z.string().optional(),
10
+ installed: z.boolean().default(true),
11
+ authenticated: z.boolean().default(true)
12
+ });
13
+ function nowIso() {
14
+ return new Date().toISOString();
15
+ }
16
+ function statusFor(config) {
17
+ return {
18
+ kind: "generic",
19
+ installed: config.installed,
20
+ ...(config.installed ? { command: "mock-cli", version: "1.0.0" } : {}),
21
+ auth: { status: config.authenticated ? "authenticated" : "unauthenticated" },
22
+ checkedAt: nowIso()
23
+ };
24
+ }
25
+ class MockSession {
26
+ sessionId;
27
+ #config;
28
+ #pending = new PendingRequests();
29
+ #approvalPolicy;
30
+ #turnCount;
31
+ #stopped = false;
32
+ constructor(config, options) {
33
+ this.#config = config;
34
+ this.#approvalPolicy = options.approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY;
35
+ if (options.resume !== undefined) {
36
+ const data = options.resume.data;
37
+ this.sessionId = data.sessionId ?? randomUUID();
38
+ this.#turnCount = data.turnCount ?? 0;
39
+ }
40
+ else {
41
+ this.sessionId = randomUUID();
42
+ this.#turnCount = 0;
43
+ }
44
+ }
45
+ async *sendTurn(input) {
46
+ if (this.#stopped) {
47
+ throw new HarnessError("session_closed", "mock session is stopped");
48
+ }
49
+ const base = { kind: "generic", sessionId: this.sessionId, at: nowIso() };
50
+ const turnId = `turn_${this.#turnCount + 1}`;
51
+ yield { ...base, type: "turn.started", turnId };
52
+ if (input.signal?.aborted === true) {
53
+ yield { ...base, type: "turn.completed", turnId, endReason: "aborted" };
54
+ return;
55
+ }
56
+ if (this.#config.approvalDetail !== undefined) {
57
+ const auto = decideApproval(this.#approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY, "exec_command_approval");
58
+ if (auto === undefined) {
59
+ const request = this.#pending.open({
60
+ requestType: "exec_command_approval",
61
+ detail: this.#config.approvalDetail
62
+ });
63
+ yield {
64
+ ...base,
65
+ type: "request.opened",
66
+ turnId,
67
+ requestId: request.requestId,
68
+ requestType: request.requestType,
69
+ ...(request.detail !== undefined ? { detail: request.detail } : {})
70
+ };
71
+ const decision = await Promise.race([
72
+ request.decision,
73
+ abortAsDecision(input.signal)
74
+ ]);
75
+ yield { ...base, type: "request.resolved", turnId, requestId: request.requestId, decision };
76
+ if (decision === "decline" || decision === "cancel") {
77
+ yield { ...base, type: "turn.completed", turnId, endReason: "aborted" };
78
+ return;
79
+ }
80
+ }
81
+ }
82
+ const reply = this.#config.replies[Math.min(this.#turnCount, this.#config.replies.length - 1)] ??
83
+ "mock reply";
84
+ this.#turnCount += 1;
85
+ yield { ...base, type: "content.delta", turnId, stream: "assistant_text", text: reply };
86
+ yield { ...base, type: "turn.completed", turnId, endReason: "completed" };
87
+ }
88
+ async respondToRequest(requestId, decision) {
89
+ if (!this.#pending.resolve(requestId, decision)) {
90
+ throw new HarnessError("protocol_parse", `unknown pending request ${requestId}`);
91
+ }
92
+ }
93
+ async interrupt() {
94
+ this.#pending.settleAll("cancel");
95
+ }
96
+ resumeCursor() {
97
+ return {
98
+ version: 1,
99
+ kind: "generic",
100
+ data: { sessionId: this.sessionId, turnCount: this.#turnCount }
101
+ };
102
+ }
103
+ async stop() {
104
+ this.#stopped = true;
105
+ this.#pending.settleAll("cancel");
106
+ }
107
+ }
108
+ class MockInstance {
109
+ kind = "generic";
110
+ #config;
111
+ #sessions = new Set();
112
+ constructor(config) {
113
+ this.#config = config;
114
+ }
115
+ status() {
116
+ return statusFor(this.#config);
117
+ }
118
+ async startSession(options) {
119
+ if (!this.#config.installed) {
120
+ throw new HarnessError("not_installed", "mock CLI is not installed");
121
+ }
122
+ if (!this.#config.authenticated) {
123
+ throw new HarnessError("not_authenticated", "mock CLI is not logged in");
124
+ }
125
+ const session = new MockSession(this.#config, options);
126
+ this.#sessions.add(session);
127
+ return session;
128
+ }
129
+ async dispose() {
130
+ for (const session of this.#sessions)
131
+ await session.stop();
132
+ this.#sessions.clear();
133
+ }
134
+ }
135
+ function abortAsDecision(signal) {
136
+ if (signal === undefined)
137
+ return new Promise(() => undefined);
138
+ if (signal.aborted)
139
+ return Promise.resolve("cancel");
140
+ return new Promise((resolve) => signal.addEventListener("abort", () => resolve("cancel"), { once: true }));
141
+ }
142
+ /**
143
+ * A fully in-memory driver implementing the whole contract: scripted replies,
144
+ * approvals, resume cursors, abort handling. The fixture for registry and
145
+ * consumer tests, and the reference implementation drivers are held against.
146
+ */
147
+ export function createMockDriver() {
148
+ return {
149
+ kind: "generic",
150
+ configSchema: mockDriverConfigSchema,
151
+ probe: async (_context) => statusFor(mockDriverConfigSchema.parse({})),
152
+ createInstance: async (config, _context) => new MockInstance(config)
153
+ };
154
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A crash-safe registry of temp directories a harness created (ephemeral CLI
3
+ * homes, worktrees). Each is recorded before use and removed on normal
4
+ * cleanup; anything still recorded at the next process start is swept, so a
5
+ * crash between creation and cleanup cannot leak dirs (or credentials) in
6
+ * /tmp forever.
7
+ */
8
+ export declare const DEFAULT_TMP_MANIFEST: string;
9
+ /** Create a tracked temp dir under `tmpdir()`, recorded in the manifest. */
10
+ export declare function createTrackedTmpDir(prefix: string, manifestPath?: string): string;
11
+ /** Remove a tracked temp dir and drop it from the manifest. */
12
+ export declare function releaseTrackedTmpDir(dir: string, manifestPath?: string): void;
13
+ /**
14
+ * Remove every temp dir still recorded in the manifest (leaked by a prior
15
+ * crash) and clear it. Returns the paths swept. Call once at CLI start.
16
+ */
17
+ export declare function sweepTrackedTmpDirs(manifestPath?: string): string[];
@@ -0,0 +1,61 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * A crash-safe registry of temp directories a harness created (ephemeral CLI
6
+ * homes, worktrees). Each is recorded before use and removed on normal
7
+ * cleanup; anything still recorded at the next process start is swept, so a
8
+ * crash between creation and cleanup cannot leak dirs (or credentials) in
9
+ * /tmp forever.
10
+ */
11
+ export const DEFAULT_TMP_MANIFEST = join(homedir(), ".routekit", "tmp-manifest.json");
12
+ function readManifest(manifestPath) {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
15
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ }
21
+ function writeManifest(manifestPath, entries) {
22
+ try {
23
+ mkdirSync(dirname(manifestPath), { recursive: true });
24
+ writeFileSync(manifestPath, `${JSON.stringify({ entries }, null, 2)}\n`);
25
+ }
26
+ catch {
27
+ // Best-effort: a manifest write failure must not break the run.
28
+ }
29
+ }
30
+ /** Create a tracked temp dir under `tmpdir()`, recorded in the manifest. */
31
+ export function createTrackedTmpDir(prefix, manifestPath = DEFAULT_TMP_MANIFEST) {
32
+ const dir = mkdtempSync(join(tmpdir(), prefix));
33
+ const entries = readManifest(manifestPath);
34
+ entries.push({ path: dir, createdAt: new Date().toISOString() });
35
+ writeManifest(manifestPath, entries);
36
+ return dir;
37
+ }
38
+ /** Remove a tracked temp dir and drop it from the manifest. */
39
+ export function releaseTrackedTmpDir(dir, manifestPath = DEFAULT_TMP_MANIFEST) {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ writeManifest(manifestPath, readManifest(manifestPath).filter((entry) => entry.path !== dir));
42
+ }
43
+ /**
44
+ * Remove every temp dir still recorded in the manifest (leaked by a prior
45
+ * crash) and clear it. Returns the paths swept. Call once at CLI start.
46
+ */
47
+ export function sweepTrackedTmpDirs(manifestPath = DEFAULT_TMP_MANIFEST) {
48
+ const entries = readManifest(manifestPath);
49
+ const swept = [];
50
+ for (const entry of entries) {
51
+ try {
52
+ rmSync(entry.path, { recursive: true, force: true });
53
+ swept.push(entry.path);
54
+ }
55
+ catch {
56
+ // Leave un-sweepable paths for a later attempt.
57
+ }
58
+ }
59
+ writeManifest(manifestPath, []);
60
+ return swept;
61
+ }