diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 801e0c067..e5d8977d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,36 @@ jobs: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + server-tests-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Fetch origin/main (worktree tests) + run: git fetch --no-tags origin main:refs/remotes/origin/main + + - name: Install dependencies + run: npm install + + - name: Build highlight dependency + run: npm run build --workspace=@getpaseo/highlight + + - name: Build relay dependency + run: npm run build --workspace=@getpaseo/relay + + - name: Run server tests + run: npm run test --workspace=@getpaseo/server + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + app-tests: runs-on: ubuntu-latest steps: diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 90e6265aa..e19a087e7 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -20,6 +20,7 @@ const mockState = vi.hoisted(() => { env?: Record; }>, }, + isCommandAvailable: vi.fn(async (_command: string) => false), runtimeModels: new Map(), reset() { for (const key of Object.keys(this.constructorArgs) as Array< @@ -27,11 +28,17 @@ const mockState = vi.hoisted(() => { >) { this.constructorArgs[key] = []; } + this.isCommandAvailable.mockReset(); + this.isCommandAvailable.mockImplementation(async (_command: string) => false); this.runtimeModels.clear(); }, }; }); +vi.mock("../../utils/executable.js", () => ({ + isCommandAvailable: mockState.isCommandAvailable, +})); + vi.mock("./providers/claude-agent.js", () => ({ ClaudeAgentClient: class ClaudeAgentClient { readonly capabilities = { @@ -69,6 +76,12 @@ vi.mock("./providers/claude-agent.js", () => ({ } async isAvailable(): Promise { + const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } }) + ?.command; + if (command?.mode === "replace") { + const { isCommandAvailable } = await import("../../utils/executable.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } return true; } }, @@ -109,6 +122,12 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({ } async isAvailable(): Promise { + const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } }) + ?.command; + if (command?.mode === "replace") { + const { isCommandAvailable } = await import("../../utils/executable.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } return true; } }, @@ -151,6 +170,12 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({ } async isAvailable(): Promise { + const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } }) + ?.command; + if (command?.mode === "replace") { + const { isCommandAvailable } = await import("../../utils/executable.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } return true; } }, @@ -433,6 +458,21 @@ describe("buildProviderRegistry", () => { expect(registry.claude).toBeUndefined(); }); + test("provider override command can be PATH-resolved and still report available", async () => { + mockState.isCommandAvailable.mockResolvedValue(true); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["claude", "--flag"], + }, + }, + }); + + await expect(registry.claude.createClient(logger).isAvailable()).resolves.toBe(true); + expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude"); + }); + test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 897e5a432..cf0f75c59 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -72,7 +72,7 @@ import type { PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { execCommand, spawnProcess } from "../../../utils/spawn.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; @@ -1128,7 +1128,7 @@ export class ClaudeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return executableExists(command.argv[0]) !== null; + return await isCommandAvailable(command.argv[0]); } return true; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ae793a462..d52d4df45 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -47,7 +47,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; @@ -4203,7 +4203,7 @@ export class CodexAppServerAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return executableExists(command.argv[0]) !== null; + return await isCommandAvailable(command.argv[0]); } return true; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9d2076cd1..cc3ca96e8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -43,7 +43,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; -import { executableExists, findExecutable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { @@ -916,7 +916,7 @@ export class OpenCodeAgentClient implements AgentClient { async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { - return executableExists(command.argv[0]) !== null; + return await isCommandAvailable(command.argv[0]); } return true; } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 66e4ff4eb..48ffff923 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -37,7 +37,6 @@ export { } from "./agent/provider-launch-config.js"; export { findExecutable, - executableExists, quoteWindowsArgument, quoteWindowsCommand, } from "../utils/executable.js"; diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index dc19b4498..5f63d84ca 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -240,6 +240,46 @@ describe("quoteWindowsCommand", () => { expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); }); + test("escapes ampersands", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); + }); + + test("escapes pipes", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); + }); + + test("doubles percent signs", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("100%")).toBe("100%%"); + }); + + test("escapes carets", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); + }); + + test("escapes multiple metacharacters", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( + "build^&^(test^|deploy^)^!^", + ); + }); + + test("quotes commands with spaces after escaping metacharacters", async () => { + setPlatform("win32"); + const { quoteWindowsCommand } = await loadExecutableModule(); + expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( + '"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"', + ); + }); + test("returns the command unchanged on non-Windows platforms", async () => { setPlatform("darwin"); const { quoteWindowsCommand } = await loadExecutableModule(); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 0c71db88e..8ed93e382 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -105,6 +105,20 @@ export async function isCommandAvailable(command: string): Promise { return (await findExecutable(command)) !== null; } +function escapeWindowsCmdValue(value: string): string { + if (process.platform !== "win32") return value; + + const isQuoted = value.startsWith('"') && value.endsWith('"'); + const unquoted = isQuoted ? value.slice(1, -1) : value; + const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1"); + + if (isQuoted || escaped.includes(" ")) { + return `"${escaped}"`; + } + + return escaped; +} + /** * When spawning with `shell: true` on Windows, the command is passed to * `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a @@ -112,10 +126,7 @@ export async function isCommandAvailable(command: string): Promise { * space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`. */ export function quoteWindowsCommand(command: string): string { - if (process.platform !== "win32") return command; - if (!command.includes(" ")) return command; - if (command.startsWith('"') && command.endsWith('"')) return command; - return `"${command}"`; + return escapeWindowsCmdValue(command); } /** @@ -124,8 +135,5 @@ export function quoteWindowsCommand(command: string): string { * child process sees it. */ export function quoteWindowsArgument(argument: string): string { - if (process.platform !== "win32") return argument; - if (!argument.includes(" ")) return argument; - if (argument.startsWith('"') && argument.endsWith('"')) return argument; - return `"${argument}"`; + return escapeWindowsCmdValue(argument); } diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 1168a59e6..bab7b047a 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,6 +1,8 @@ import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { promisify } from "node:util"; +import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; + const execFileAsync = promisify(execFile); interface ExecCommandOptions { @@ -16,12 +18,6 @@ interface ExecCommandResult { stderr: string; } -function quoteForCmd(value: string): string { - if (!value.includes(" ")) return value; - if (value.startsWith('"') && value.endsWith('"')) return value; - return `"${value}"`; -} - export function spawnProcess( command: string, args: string[], @@ -29,8 +25,8 @@ export function spawnProcess( ): ChildProcess { const isWindows = process.platform === "win32"; - const resolvedCommand = isWindows ? quoteForCmd(command) : command; - const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; + const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; return spawn(resolvedCommand, resolvedArgs, { ...options, @@ -45,8 +41,8 @@ export async function execCommand( options?: ExecCommandOptions, ): Promise { const isWindows = process.platform === "win32"; - const resolvedCommand = isWindows ? quoteForCmd(command) : command; - const resolvedArgs = isWindows ? args.map(quoteForCmd) : args; + const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; + const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; return execFileAsync(resolvedCommand, resolvedArgs, { cwd: options?.cwd,