diff --git a/.changeset/fn-6911-droid-cli-no-boot-spawn.md b/.changeset/fn-6911-droid-cli-no-boot-spawn.md new file mode 100644 index 0000000000..6f23dc32e9 --- /dev/null +++ b/.changeset/fn-6911-droid-cli-no-boot-spawn.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Prevent the bundled Droid CLI extension from starting local `droid` probes during server boot; validation now runs only when a Droid stream is actually used while existing probe paths remain non-interactive and timeout-bounded. diff --git a/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts b/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts index 2e30bddc42..00fe8e8ea7 100644 --- a/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts +++ b/packages/cli/src/commands/__tests__/droid-cli-extension.test.ts @@ -1,7 +1,14 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { tempWorkspace } from "@fusion/test-utils"; +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + import { resolveDroidCliExtension, resolveDroidCliExtensionPaths, @@ -22,11 +29,15 @@ describe("resolveDroidCliExtension", () => { }); describe("resolveDroidCliExtensionPaths", () => { - it("returns empty when useDroidCli is off (default)", () => { + it("returns empty when useDroidCli is off (default) without spawning droid", () => { + spawnMock.mockClear(); + const result = resolveDroidCliExtensionPaths({}); + expect(result.paths).toEqual([]); expect(result.warning).toBeUndefined(); expect(result.resolution).toBeNull(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("returns empty when useDroidCli is explicitly false", () => { diff --git a/packages/droid-cli/index.ts b/packages/droid-cli/index.ts index 8efac5b7e1..aeb0c373a0 100644 --- a/packages/droid-cli/index.ts +++ b/packages/droid-cli/index.ts @@ -24,17 +24,21 @@ type StreamSimpleHandler = NonNullable { if (cliValidationPromise) return cliValidationPromise; cliValidationPromise = (async () => { - const presence = await validateCliPresenceAsync(); - if (!presence.ok) { - console.warn(`[droid-cli] ${presence.error.message}`); - return; + try { + const presence = await validateCliPresenceAsync(); + if (!presence.ok) { + console.warn(`[droid-cli] ${presence.error.message}`); + return; + } + await validateCliAuthAsync(); + } catch (error) { + console.warn("[droid-cli] CLI validation failed; continuing without blocking the session", error); } - await validateCliAuthAsync(); })(); return cliValidationPromise; } -async function getDiscoveredModels() { +export async function discoverDroidProviderModels() { if (!discoveredModelsPromise) { discoveredModelsPromise = (async () => { try { @@ -104,6 +108,7 @@ function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) { api: "droid-cli", models, streamSimple: ((model, context, options) => { + void runCliValidationOnce(); const configPath = ensureMcpConfig( pi, (context as { tools?: ReadonlyArray<{ name: string; description: string; parameters: Record }> }).tools, @@ -119,10 +124,12 @@ function registerDroidProvider(pi: ExtensionAPI, models: DiscoveredModel[]) { export default function (pi: ExtensionAPI) { /* + FNXC:CliRuntime 2026-06-21-18:43: + Engine and dashboard startup must not start the local Droid CLI merely because the optional extension loaded. Register the provider synchronously with an empty model list, defer validation until an actual droid stream starts, and leave model discovery to explicit picker/status callers so boot with `useDroidCli` enabled still performs zero `droid` spawns. + FNXC:CliRuntime 2026-06-21-12:00: - Engine and dashboard startup must not wait for local Droid CLI probes. Register the provider synchronously with an empty model list, then launch presence/auth/model discovery as fire-and-forget bounded probes so a missing or wedged `droid` binary cannot stall extension loading. + Engine and dashboard startup must not wait for local Droid CLI probes. Every surviving validation/discovery helper remains fire-and-forget, bounded, non-interactive, and resolve-only so a missing or wedged `droid` binary cannot stall extension loading or a session start. */ - void runCliValidationOnce(); pi.on("session_start", async () => { const allTools = pi.getAllTools(); @@ -136,14 +143,4 @@ export default function (pi: ExtensionAPI) { } catch (err) { console.error("[droid-cli] Failed to register provider:", err); } - - void (async () => { - const models = await getDiscoveredModels(); - if (models.length === 0) return; - try { - registerDroidProvider(pi, models); - } catch (err) { - console.error("[droid-cli] Failed to refresh discovered provider models:", err); - } - })(); } diff --git a/packages/droid-cli/src/__tests__/index.test.ts b/packages/droid-cli/src/__tests__/index.test.ts index e105d8e767..971806d040 100644 --- a/packages/droid-cli/src/__tests__/index.test.ts +++ b/packages/droid-cli/src/__tests__/index.test.ts @@ -58,59 +58,7 @@ describe("droid-cli extension entrypoint", () => { vi.restoreAllMocks(); }); - it("registers provider droid-cli with discovered model mapping and streamSimple", async () => { - const registerProvider = vi.fn(); - const mockPi = { - registerProvider, - on: vi.fn(), - getAllTools: vi.fn(() => []), - setActiveTools: vi.fn(), - }; - - const mod = await import("../../index"); - mod.default(mockPi as never); - await flushAsyncRegistration(); - - expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1); - expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1); - expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); - - expect(registerProvider).toHaveBeenCalledTimes(2); - const [initialProviderId, initialConfig] = registerProvider.mock.calls[0] as [string, { - models: unknown[]; - }]; - expect(initialProviderId).toBe("droid-cli"); - expect(initialConfig.models).toEqual([]); - - const [providerId, config] = registerProvider.mock.calls.at(-1) as [string, { - baseUrl: string; - api: string; - apiKey: string; - models: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>; - streamSimple: Function; - }]; - - expect(providerId).toBe("droid-cli"); - expect(config.baseUrl).toBe("droid-cli"); - expect(config.api).toBe("droid-cli"); - expect(config.apiKey).toBe("unused"); - expect(config.models).toEqual([ - expect.objectContaining({ id: "droid-pro", name: "droid-pro", contextWindow: 200_000, maxTokens: 8_192 }), - expect.objectContaining({ id: "droid-max", name: "droid-max", contextWindow: 200_000, maxTokens: 8_192 }), - ]); - expect(typeof config.streamSimple).toBe("function"); - }); - - it("registers the provider synchronously without awaiting droid probes", async () => { - let resolvePresence!: (value: { ok: true }) => void; - let resolveDiscovery!: (value: string[]) => void; - runtimeMocks.validateCliPresenceAsync.mockImplementation( - () => new Promise((resolve) => { resolvePresence = resolve; }), - ); - runtimeMocks.discoverDroidModels.mockImplementation( - () => new Promise((resolve) => { resolveDiscovery = resolve; }), - ); - + it("registers provider droid-cli synchronously without starting droid probes or discovery", async () => { const registerProvider = vi.fn(); const mockPi = { registerProvider, @@ -121,23 +69,63 @@ describe("droid-cli extension entrypoint", () => { const mod = await import("../../index"); const result = mod.default(mockPi as never); - - expect(result).toBeUndefined(); - expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1); - expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); - expect(registerProvider).toHaveBeenCalledTimes(1); - expect(registerProvider).toHaveBeenCalledWith("droid-cli", expect.objectContaining({ models: [] })); - expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled(); - - resolvePresence({ ok: true }); - resolveDiscovery(["droid-pro"]); await flushAsyncRegistration(); + expect(result).toBeUndefined(); + expect(runtimeMocks.validateCliPresenceAsync).not.toHaveBeenCalled(); + expect(runtimeMocks.validateCliAuthAsync).not.toHaveBeenCalled(); + expect(runtimeMocks.discoverDroidModels).not.toHaveBeenCalled(); + + expect(registerProvider).toHaveBeenCalledTimes(1); + const [providerId, config] = registerProvider.mock.calls[0] as [string, { + baseUrl: string; + api: string; + apiKey: string; + models: unknown[]; + streamSimple: Function; + }]; + + expect(providerId).toBe("droid-cli"); + expect(config.baseUrl).toBe("droid-cli"); + expect(config.api).toBe("droid-cli"); + expect(config.apiKey).toBe("unused"); + expect(config.models).toEqual([]); + expect(typeof config.streamSimple).toBe("function"); + }); + + it("runs validation once when a droid stream is actually used", async () => { + const registerProvider = vi.fn(); + const mockPi = { + registerProvider, + on: vi.fn(), + getAllTools: vi.fn(() => []), + setActiveTools: vi.fn(), + }; + + const mod = await import("../../index"); + mod.default(mockPi as never); + const config = registerProvider.mock.calls[0]?.[1] as { + streamSimple: (model: unknown, context: unknown, options?: Record) => unknown; + }; + + config.streamSimple({ id: "droid-pro" }, { messages: [] }, {}); + config.streamSimple({ id: "droid-pro" }, { messages: [] }, {}); + await flushAsyncRegistration(); + + expect(runtimeMocks.validateCliPresenceAsync).toHaveBeenCalledTimes(1); expect(runtimeMocks.validateCliAuthAsync).toHaveBeenCalledTimes(1); - expect(registerProvider).toHaveBeenCalledTimes(2); - expect(registerProvider.mock.calls[1]?.[1]).toMatchObject({ - models: [expect.objectContaining({ id: "droid-pro" })], - }); + expect(runtimeMocks.discoverDroidModels).not.toHaveBeenCalled(); + }); + + it("discovers provider models only when explicitly requested", async () => { + const mod = await import("../../index"); + + await expect(mod.discoverDroidProviderModels()).resolves.toEqual([ + expect.objectContaining({ id: "droid-pro", name: "droid-pro", contextWindow: 200_000, maxTokens: 8_192 }), + expect.objectContaining({ id: "droid-max", name: "droid-max", contextWindow: 200_000, maxTokens: 8_192 }), + ]); + + expect(runtimeMocks.discoverDroidModels).toHaveBeenCalledTimes(1); }); it("activates all registered tools on session_start", async () => { @@ -177,6 +165,10 @@ describe("droid-cli extension entrypoint", () => { const mod = await import("../../index"); mod.default(mockPi as never); + const config = mockPi.registerProvider.mock.calls[0]?.[1] as { + streamSimple: (model: unknown, context: unknown, options?: Record) => unknown; + }; + config.streamSimple({ id: "droid-pro" }, { messages: [] }, {}); await flushAsyncRegistration(); expect(warnSpy).toHaveBeenCalledWith("[droid-cli] droid CLI missing"); @@ -188,21 +180,9 @@ describe("droid-cli extension entrypoint", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); runtimeMocks.discoverDroidModels.mockRejectedValue(new Error("boom")); - const registerProvider = vi.fn(); - const mockPi = { - registerProvider, - on: vi.fn(), - getAllTools: vi.fn(() => []), - setActiveTools: vi.fn(), - }; - const mod = await import("../../index"); - mod.default(mockPi as never); - await flushAsyncRegistration(); + await expect(mod.discoverDroidProviderModels()).resolves.toEqual([]); - const config = registerProvider.mock.calls[0]?.[1] as { models: unknown[] }; - expect(config.models).toEqual([]); - expect(registerProvider).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( "[droid-cli] model auto-discovery failed; registering provider with empty model list", expect.any(Error), diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts index 7580f79ca7..503b72ad47 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts @@ -80,7 +80,12 @@ describe("discoverDroidModels", () => { const models = await discoverDroidModels(); expect(spawnMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.anything()); + expect(spawnMock).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.objectContaining({ + stdio: ["ignore", "pipe", "ignore"], + })); + const options = spawnMock.mock.calls[0]?.[2] as { stdio: string[] }; + expect(options.stdio).not.toBe("inherit"); + expect(options.stdio).not.toContain("inherit"); expect(models).toContain("claude-opus-4-8"); expect(models).toContain("custom:Kimi-K2.5-Turbo-0"); }); diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts index 706eea7dc3..c370cba168 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/probe.test.ts @@ -42,6 +42,11 @@ describe("probeDroidBinary", () => { const result = await probeDroidBinary({ timeoutMs: 10 }); expect(result.available).toBe(false); expect(result.reason).toContain("Binary not found or not executable"); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ + stdio: ["ignore", "pipe", "pipe"], + })); + const options = spawnMock.mock.calls[0]?.[2] as { stdio: string[] }; + expect(options.stdio).not.toContain("inherit"); }); it("returns unavailable and SIGKILLs when the binary hangs", async () => { @@ -85,6 +90,9 @@ describe("probeDroidBinary", () => { const result = await probeDroidBinary(); expect(result.available).toBe(true); expect(result.version).toBe("droid 1.2.3"); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ + stdio: ["ignore", "pipe", "pipe"], + })); }); it("uses binary path from plugin settings", async () => { diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts new file mode 100644 index 0000000000..47ad5de065 --- /dev/null +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/process-manager.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { buildDroidSpawnArgs, spawnDroid } from "../process-manager.js"; + +function makeProc() { + const proc = new EventEmitter() as any; + proc.killed = false; + proc.exitCode = null; + proc.pid = 123; + proc.kill = vi.fn(() => { + proc.killed = true; + }); + return proc; +} + +describe("Droid agent spawn invariants", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds a non-interactive print-mode stream-json invocation", () => { + const args = buildDroidSpawnArgs("droid-pro", undefined, { + effort: "high", + mcpConfigPath: "/tmp/mcp.json", + newSessionId: "session-1", + }); + + expect(args[0]).toBe("-p"); + expect(args).toEqual(expect.arrayContaining([ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--model", + "droid-pro", + "--session-id", + "session-1", + "--effort", + "high", + "--mcp-config", + "/tmp/mcp.json", + ])); + expect(args).not.toContain("models"); + expect(args).not.toContain("model"); + }); + + it("spawns droid with piped stdio and never inherits a TTY", () => { + const proc = makeProc(); + spawnMock.mockReturnValueOnce(proc); + + expect(spawnDroid("droid-pro", undefined, { cwd: "/tmp/project" })).toBe(proc); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [binary, args, options] = spawnMock.mock.calls[0] as [string, string[], { stdio: string[]; cwd: string }]; + expect(binary).toBe("droid"); + expect(args[0]).toBe("-p"); + expect(args).toEqual(expect.arrayContaining(["--input-format", "stream-json"])); + expect(options.cwd).toBe("/tmp/project"); + expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]); + expect(options.stdio).not.toBe("inherit"); + expect(options.stdio).not.toContain("inherit"); + }); +}); diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts index d3e5848264..c6e30d4c8b 100644 --- a/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/startup-probes.test.ts @@ -37,9 +37,11 @@ describe("Droid startup validation probes", () => { await expect(validateCliPresenceAsync()).resolves.toMatchObject({ ok: false }); expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ stdio: "ignore" })); + const options = spawnMock.mock.calls[0]?.[2] as { stdio: string }; + expect(options.stdio).not.toBe("inherit"); }); - it("resolves ok when `droid --version` exits 0", async () => { + it("resolves ok when `droid --version` exits 0 without inheriting stdio", async () => { spawnMock.mockImplementationOnce(() => { const proc = makeProbeProc(); queueMicrotask(() => proc.emit("exit", 0)); @@ -47,6 +49,7 @@ describe("Droid startup validation probes", () => { }); await expect(validateCliPresenceAsync()).resolves.toEqual({ ok: true }); + expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ stdio: "ignore" })); }); it("SIGKILLs and resolves unavailable when `droid --version` hangs", async () => {