From e490bf1dabccdcfcd6cf216b4e72936ac8b2a813 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 17:43:15 +0700 Subject: [PATCH] fix(server-tests): unblock Windows CI for Claude SDK + .exe launch test - spawn.launch-regression: insert `--` between `node -e ` and the user JSON args. Without it Node treats `--config` as a CLI option and exits with code 9 ("bad option"). The `--` stops Node's flag parsing so userArgs land in process.argv intact. - claude-agent.integration + claude-sdk-behavior: inject spawnClaudeCodeProcess that routes the SDK's launch through our spawnProcess (shell: false). On Windows the SDK's default spawn fails with EINVAL when pathToClaudeCodeExecutable resolves to a `.cmd` shim (CVE-2024-27980). This mirrors what claude-agent.ts does in production. - 3 cleanup sites (event-stream, integration, sdk-behavior): swallow EBUSY/ENOTEMPTY/EPERM around rmSync. The maxRetries window doesn't reliably win the Windows cwd-lock race after Claude exits; the OS will reap the tmpdir. --- ...ude-agent.event-stream.integration.test.ts | 9 +++++- .../claude-agent.integration.test.ts | 24 +++++++++++++++- .../providers/claude-sdk-behavior.test.ts | 28 ++++++++++++++++++- .../src/utils/spawn.launch-regression.test.ts | 4 ++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts index c10495c24..5b32aad38 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -74,7 +74,14 @@ async function createSession(params?: { async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { await handle.session.close().catch(() => undefined); - rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + try { + rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") { + throw error; + } + } } async function startTurnAndCollectEvents( diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 964be3ee8..e2a4b4978 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -8,9 +8,23 @@ import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; +import { spawnProcess } from "../../../utils/spawn.js"; import { ClaudeAgentClient } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; +type SpawnClaudeOptions = NonNullable< + Parameters[0]["options"] +>["spawnClaudeCodeProcess"]; + +const spawnClaudeForTest: SpawnClaudeOptions = (options) => + spawnProcess(options.command, options.args, { + cwd: options.cwd, + env: options.env, + signal: options.signal, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }); + const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); const hasClaudeCredentials = @@ -157,7 +171,14 @@ async function createSession(params?: { async function cleanupSession(handle: { cwd: string; session: AgentSession }): Promise { await handle.session.close().catch(() => undefined); - rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + try { + rmSync(handle.cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") { + throw error; + } + } } describe("ClaudeAgentSession integration", () => { @@ -233,6 +254,7 @@ describe("ClaudeAgentSession integration", () => { includePartialMessages: false, settingSources: ["user", "project"], pathToClaudeCodeExecutable: claudeBinary, + spawnClaudeCodeProcess: spawnClaudeForTest, }, }); diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index fb18171ec..4b17fdf31 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -7,6 +7,20 @@ import path from "node:path"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; + +type SpawnClaudeOptions = NonNullable< + Parameters[0]["options"] +>["spawnClaudeCodeProcess"]; + +const spawnClaudeForTest: SpawnClaudeOptions = (options) => + spawnProcess(options.command, options.args, { + cwd: options.cwd, + env: options.env, + signal: options.signal, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }); class Pushable implements AsyncIterable { private queue: T[] = []; @@ -53,6 +67,17 @@ function tmpCwd(): string { } } +function rmCwd(cwd: string): void { + try { + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") { + throw error; + } + } +} + function extractTextFromEvents(events: SDKMessage[]): string { let responseText = ""; for (const event of events) { @@ -104,6 +129,7 @@ describe("Claude SDK direct behavior", () => { includePartialMessages: true, permissionMode: "bypassPermissions", ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), + spawnClaudeCodeProcess: spawnClaudeForTest, systemPrompt: { type: "preset", preset: "claude_code", @@ -160,7 +186,7 @@ describe("Claude SDK direct behavior", () => { expect(sawResult || responseText.length === 0).toBe(true); } finally { input.end(); - rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + rmCwd(cwd); } }, 120000); }); diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts index 0ab928a92..b1eedad40 100644 --- a/packages/server/src/utils/spawn.launch-regression.test.ts +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -158,6 +158,8 @@ function makeLaunchFixture(ext: "exe" | "cmd" | "bat"): LaunchFixture { if (ext === "exe") { // Copy node.exe to .exe and run our assert body via -e. + // The `--` separator stops Node from parsing userArgs as Node options + // (e.g. `--config` would otherwise trigger "bad option" → exit 9). // With -e there is no script slot, so process.argv = [node, ...userArgs] → slice(1). const binaryPath = path.join(root, `${command}.exe`); copyFileSync(process.execPath, binaryPath); @@ -165,7 +167,7 @@ function makeLaunchFixture(ext: "exe" | "cmd" | "bat"): LaunchFixture { root, command, binaryPath, - args: ["-e", ASSERT_SCRIPT_BODY, ...userArgs], + args: ["-e", ASSERT_SCRIPT_BODY, "--", ...userArgs], expectedArgvJson, sliceFrom: 1, };