Add a supervised Cursor CLI runtime for routed Cursor model sessions. - Stream Cursor prompt output into Fusion session callbacks with resume support. - Support Windows Cursor shims, inactivity-based supervision, and cursor-agent to cursor fallback execution. - Route Cursor CLI models through the bundled plugin and document the runtime contract. Files changed: .changeset/fn-9097-cursor-cli-runtime.md | 7 ++ docs/cursor-cli-contract.md | 21 ++++ docs/settings-reference.md | 4 +- .../cli-runtime-routing-conformance.test.ts | 11 +- .../engine/src/agents/agent-session-helpers.ts | 6 +- packages/engine/src/agents/cli-provider-routing.ts | 27 ++--- plugins/fusion-plugin-cursor-runtime/README.md | 21 ++++ plugins/fusion-plugin-cursor-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 25 ++++- .../src/__tests__/prompt-transport.test.ts | 58 ++++++++++ .../src/__tests__/runtime-adapter.test.ts | 44 +++++--- .../src/__tests__/stream-parser.test.ts | 12 +++ .../fusion-plugin-cursor-runtime/src/cli-spawn.ts | 57 +++++++++- plugins/fusion-plugin-cursor-runtime/src/index.ts | 4 +- .../src/prompt-transport.ts | 120 +++++++++++++++++++++ .../src/runtime-adapter.ts | 59 ++++++---- .../src/stream-parser.ts | 43 ++++++++ plugins/fusion-plugin-cursor-runtime/src/types.ts | 18 ++-- pnpm-lock.yaml | 3 + 19 files changed, 458 insertions(+), 83 deletions(-) Fusion-Task-Id: FN-9097 Fusion-Task-Lineage: a4eed861-6059-4528-9f80-3426f5ccad58 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: vi.fn(), spawnSync: vi.fn() }));
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { assertCmdBoundarySafe, quoteCmdArgument, resolvePowerShellExecutable, runCursorCommand } from "../cli-spawn.js";
|
|
|
|
function mockPlatform(platform: NodeJS.Platform) {
|
|
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
|
}
|
|
|
|
function createMockChild() {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
};
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
child.kill = vi.fn();
|
|
vi.mocked(spawn).mockReturnValue(child as never);
|
|
return child;
|
|
}
|
|
|
|
describe("runCursorCommand", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("uses the Windows shell so PATH .cmd and .bat Cursor shims can run", async () => {
|
|
mockPlatform("win32");
|
|
const child = createMockChild();
|
|
|
|
const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000);
|
|
|
|
expect(spawn).toHaveBeenCalledWith("cursor-agent", ["--version"], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
shell: true,
|
|
});
|
|
|
|
child.stdout.write("cursor-agent 1.2.3\n");
|
|
child.stderr.write("diagnostic\n");
|
|
child.emit("close", 0);
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
code: 0,
|
|
stdout: "cursor-agent 1.2.3\n",
|
|
stderr: "diagnostic\n",
|
|
});
|
|
});
|
|
|
|
it("keeps non-Windows Cursor invocations on direct spawn", async () => {
|
|
mockPlatform("darwin");
|
|
const child = createMockChild();
|
|
|
|
const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000);
|
|
|
|
expect(spawn).toHaveBeenCalledWith("cursor-agent", ["--version"], {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
shell: false,
|
|
});
|
|
|
|
child.emit("close", 0);
|
|
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
|
|
});
|
|
|
|
it("returns spawn errors with diagnostics instead of empty stderr", async () => {
|
|
mockPlatform("win32");
|
|
const child = createMockChild();
|
|
|
|
const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000);
|
|
child.emit("error", Object.assign(new Error("spawn cursor-agent ENOENT"), { code: "ENOENT" }));
|
|
|
|
const result = await resultPromise;
|
|
expect(result.code).toBe(127);
|
|
expect(result.stderr).toContain("spawn error: ENOENT: spawn cursor-agent ENOENT");
|
|
});
|
|
|
|
it("kills timed-out Cursor commands best-effort and resolves once", async () => {
|
|
vi.useFakeTimers();
|
|
mockPlatform("linux");
|
|
const child = createMockChild();
|
|
|
|
const resultPromise = runCursorCommand("cursor-agent", ["models", "--json"], 25);
|
|
child.stdout.write("partial");
|
|
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
|
|
await expect(resultPromise).resolves.toEqual({ code: 124, stdout: "partial", stderr: "" });
|
|
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
|
|
|
child.emit("close", 0);
|
|
await expect(resultPromise).resolves.toMatchObject({ code: 124 });
|
|
});
|
|
});
|
|
|
|
describe("cmd boundary validation", () => {
|
|
it("falls back to Windows PowerShell when pwsh is not resolvable", () => {
|
|
vi.mocked(spawnSync).mockReturnValue({ status: 1, stdout: "" } as never);
|
|
expect(resolvePowerShellExecutable()).toBe("powershell.exe");
|
|
vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: "C:\\Program Files\\PowerShell\\7\\pwsh.exe\n" } as never);
|
|
expect(resolvePowerShellExecutable()).toBe("pwsh.exe");
|
|
});
|
|
|
|
it("allows Cursor bracketed parameterized model identifiers", () => {
|
|
const model = "claude-opus-4-8[context=1m,effort=high]";
|
|
expect(() => assertCmdBoundarySafe(model)).not.toThrow();
|
|
expect(quoteCmdArgument(model)).toBe(`"${model}"`);
|
|
});
|
|
|
|
it("rejects cmd control characters rather than escaping them", () => {
|
|
expect(() => assertCmdBoundarySafe("model&payload")).toThrow(/&/);
|
|
});
|
|
});
|