diff --git a/.changeset/fn-6808-cli-probe-unhandled-rejection.md b/.changeset/fn-6808-cli-probe-unhandled-rejection.md new file mode 100644 index 0000000000..d9a26934e9 --- /dev/null +++ b/.changeset/fn-6808-cli-probe-unhandled-rejection.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Prevent bundled Droid and Claude CLI auth/presence probes from surfacing unhandled promise rejections when `spawn` throws synchronously, such as when test guards block real AI CLI auth commands. These probes now resolve as unavailable/unauthenticated instead of rejecting from fire-and-forget validation paths. diff --git a/packages/droid-cli/src/__tests__/process-manager.test.ts b/packages/droid-cli/src/__tests__/process-manager.test.ts index 7e9af18e8d..a4df93ae37 100644 --- a/packages/droid-cli/src/__tests__/process-manager.test.ts +++ b/packages/droid-cli/src/__tests__/process-manager.test.ts @@ -415,6 +415,16 @@ describe("validateCliPresenceAsync", () => { const result = await validateCliPresenceAsync(); expect(result.ok).toBe(false); }); + + it("resolves ok=false instead of rejecting when droid spawn throws synchronously", async () => { + (spawn as any).mockImplementationOnce(() => { + throw new Error("Real AI CLI launch blocked during tests: droid --version"); + }); + + await expect(validateCliPresenceAsync()).resolves.toMatchObject({ + ok: false, + }); + }); }); describe("validateCliAuthAsync", () => { @@ -452,6 +462,21 @@ describe("validateCliAuthAsync", () => { ); warnSpy.mockRestore(); }); + + it("resolves false instead of rejecting when droid auth spawn throws synchronously", async () => { + (spawn as any).mockImplementationOnce(() => { + throw new Error( + "Real AI CLI launch blocked during tests: droid auth status", + ); + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(validateCliAuthAsync()).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not authenticated"), + ); + warnSpy.mockRestore(); + }); }); describe("CLI flags", () => { diff --git a/packages/pi-claude-cli/src/__tests__/process-manager.test.ts b/packages/pi-claude-cli/src/__tests__/process-manager.test.ts index 5c4d1c5e4b..6ddd8bf916 100644 --- a/packages/pi-claude-cli/src/__tests__/process-manager.test.ts +++ b/packages/pi-claude-cli/src/__tests__/process-manager.test.ts @@ -414,6 +414,16 @@ describe("validateCliPresenceAsync", () => { const result = await validateCliPresenceAsync(); expect(result.ok).toBe(false); }); + + it("resolves ok=false instead of rejecting when claude spawn throws synchronously", async () => { + (spawn as any).mockImplementationOnce(() => { + throw new Error("Real AI CLI launch blocked during tests: claude --version"); + }); + + await expect(validateCliPresenceAsync()).resolves.toMatchObject({ + ok: false, + }); + }); }); describe("validateCliAuthAsync", () => { @@ -451,6 +461,21 @@ describe("validateCliAuthAsync", () => { ); warnSpy.mockRestore(); }); + + it("resolves false instead of rejecting when claude auth spawn throws synchronously", async () => { + (spawn as any).mockImplementationOnce(() => { + throw new Error( + "Real AI CLI launch blocked during tests: claude auth status", + ); + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await expect(validateCliAuthAsync()).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not authenticated"), + ); + warnSpy.mockRestore(); + }); }); describe("CLI flags", () => { diff --git a/packages/pi-claude-cli/src/process-manager.ts b/packages/pi-claude-cli/src/process-manager.ts index 816dc2b902..c959d82f64 100644 --- a/packages/pi-claude-cli/src/process-manager.ts +++ b/packages/pi-claude-cli/src/process-manager.ts @@ -219,10 +219,20 @@ export function captureStderr(proc: ChildProcess): () => string { * does this on every chat send), sync probes freeze every other request. * This async variant uses spawn so the loop keeps turning while the subprocess * starts up. + * + * FNXC:CliRuntime 2026-06-20-17:25: + * FN-6808/FN-6801 require this fire-and-forget auth/presence probe to never reject. Catch synchronous spawn throws from the Vitest child-process guard or platform launch errors and resolve 127, matching the async error sentinel so callers degrade to unauthenticated/not-present instead of surfacing unhandled promise rejections. */ function runClaudeProbe(args: string[], timeoutMs = 5000): Promise { return new Promise((resolve) => { - const proc = spawn("claude", args, { stdio: "ignore" }); + let proc: ChildProcess; + try { + proc = spawn("claude", args, { stdio: "ignore" }); + } catch { + resolve(127); + return; + } + const timer = setTimeout(() => { try { proc.kill("SIGKILL"); diff --git a/plugins/fusion-plugin-droid-runtime/src/process-manager.ts b/plugins/fusion-plugin-droid-runtime/src/process-manager.ts index 0ed64a6904..ad22ac5b9f 100644 --- a/plugins/fusion-plugin-droid-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-droid-runtime/src/process-manager.ts @@ -219,10 +219,20 @@ export function captureStderr(proc: ChildProcess): () => string { * does this on every chat send), sync probes freeze every other request. * This async variant uses spawn so the loop keeps turning while the subprocess * starts up. + * + * FNXC:CliRuntime 2026-06-20-17:25: + * FN-6808/FN-6801 require this fire-and-forget auth/presence probe to never reject. Catch synchronous spawn throws from the Vitest child-process guard or platform launch errors and resolve 127, matching the async error sentinel so callers degrade to unauthenticated/not-present instead of surfacing unhandled promise rejections. */ function runDroidProbe(args: string[], timeoutMs = 45000): Promise { return new Promise((resolve) => { - const proc = spawn("droid", args, { stdio: "ignore" }); + let proc: ChildProcess; + try { + proc = spawn("droid", args, { stdio: "ignore" }); + } catch { + resolve(127); + return; + } + const timer = setTimeout(() => { try { proc.kill("SIGKILL"); @@ -275,11 +285,22 @@ export async function validateCliAuthAsync(): Promise { } export async function discoverDroidModels(): Promise { - const attempts: string[][] = [["models", "--json"], ["model", "list", "--json"], ["models"]]; + const attempts: string[][] = [ + ["models", "--json"], + ["model", "list", "--json"], + ["models"], + ]; for (const args of attempts) { const models = await new Promise((resolve) => { - const proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] }); + let proc: ChildProcess; + try { + proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] }); + } catch { + resolve(null); + return; + } + let out = ""; proc.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString();