diff --git a/package.json b/package.json index 68098ad8..4aee6527 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "node scripts/dev-runner.mjs watch", - "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", "dev:once": "node scripts/dev-runner.mjs dev", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", @@ -34,6 +34,7 @@ }, "devDependencies": { "@changesets/cli": "^2.30.0", + "cross-env": "^10.1.0", "@playwright/test": "^1.58.2", "esbuild": "^0.27.3", "typescript": "^5.7.3", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 76efba86..3d273cd9 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -15,6 +15,11 @@ interface RunningProcess { graceSec: number; } +interface SpawnTarget { + command: string; + args: string[]; +} + type ChildProcessWithEvents = ChildProcess & { on(event: "error", listener: (err: Error) => void): ChildProcess; on( @@ -125,6 +130,78 @@ export function defaultPathForPlatform() { return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; } +function windowsPathExts(env: NodeJS.ProcessEnv): string[] { + return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); +} + +async function pathExists(candidate: string) { + try { + await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + const hasPathSeparator = command.includes("/") || command.includes("\\"); + if (hasPathSeparator) { + const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); + return (await pathExists(absolute)) ? absolute : null; + } + + const pathValue = env.PATH ?? env.Path ?? ""; + const delimiter = process.platform === "win32" ? ";" : ":"; + const dirs = pathValue.split(delimiter).filter(Boolean); + const exts = process.platform === "win32" ? windowsPathExts(env) : [""]; + const hasExtension = process.platform === "win32" && path.extname(command).length > 0; + + for (const dir of dirs) { + const candidates = + process.platform === "win32" + ? hasExtension + ? [path.join(dir, command)] + : exts.map((ext) => path.join(dir, `${command}${ext}`)) + : [path.join(dir, command)]; + for (const candidate of candidates) { + if (await pathExists(candidate)) return candidate; + } + } + + return null; +} + +function quoteForCmd(arg: string) { + if (!arg.length) return '""'; + const escaped = arg.replace(/"/g, '""'); + return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; +} + +async function resolveSpawnTarget( + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, +): Promise { + const resolved = await resolveCommandPath(command, cwd, env); + const executable = resolved ?? command; + + if (process.platform !== "win32") { + return { command: executable, args }; + } + + if (/\.(cmd|bat)$/i.test(executable)) { + const shell = env.ComSpec || process.env.ComSpec || "cmd.exe"; + const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" "); + return { + command: shell, + args: ["/d", "/s", "/c", commandLine], + }; + } + + return { command: executable, args }; +} + export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (typeof env.PATH === "string" && env.PATH.length > 0) return env; if (typeof env.Path === "string" && env.Path.length > 0) return env; @@ -169,36 +246,12 @@ export async function ensureAbsoluteDirectory( } export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { - const hasPathSeparator = command.includes("/") || command.includes("\\"); - if (hasPathSeparator) { + const resolved = await resolveCommandPath(command, cwd, env); + if (resolved) return; + if (command.includes("/") || command.includes("\\")) { const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); - try { - await fs.access(absolute, fsConstants.X_OK); - } catch { - throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); - } - return; + throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); } - - const pathValue = env.PATH ?? env.Path ?? ""; - const delimiter = process.platform === "win32" ? ";" : ":"; - const dirs = pathValue.split(delimiter).filter(Boolean); - const windowsExt = process.platform === "win32" - ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") - : [""]; - - for (const dir of dirs) { - for (const ext of windowsExt) { - const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command); - try { - await fs.access(candidate, fsConstants.X_OK); - return; - } catch { - // continue scanning PATH - } - } - } - throw new Error(`Command not found in PATH: "${command}"`); } @@ -220,78 +273,82 @@ export async function runChildProcess( return new Promise((resolve, reject) => { const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env }); - const child = spawn(command, args, { - cwd: opts.cwd, - env: mergedEnv, - shell: false, - stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], - }) as ChildProcessWithEvents; + void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) + .then((target) => { + const child = spawn(target.command, target.args, { + cwd: opts.cwd, + env: mergedEnv, + shell: false, + stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], + }) as ChildProcessWithEvents; - if (opts.stdin != null && child.stdin) { - child.stdin.write(opts.stdin); - child.stdin.end(); - } + if (opts.stdin != null && child.stdin) { + child.stdin.write(opts.stdin); + child.stdin.end(); + } - runningProcesses.set(runId, { child, graceSec: opts.graceSec }); + runningProcesses.set(runId, { child, graceSec: opts.graceSec }); - let timedOut = false; - let stdout = ""; - let stderr = ""; - let logChain: Promise = Promise.resolve(); + let timedOut = false; + let stdout = ""; + let stderr = ""; + let logChain: Promise = Promise.resolve(); - const timeout = - opts.timeoutSec > 0 - ? setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, Math.max(1, opts.graceSec) * 1000); - }, opts.timeoutSec * 1000) - : null; + const timeout = + opts.timeoutSec > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, Math.max(1, opts.graceSec) * 1000); + }, opts.timeoutSec * 1000) + : null; - child.stdout?.on("data", (chunk: unknown) => { - const text = String(chunk); - stdout = appendWithCap(stdout, text); - logChain = logChain - .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); - }); - - child.stderr?.on("data", (chunk: unknown) => { - const text = String(chunk); - stderr = appendWithCap(stderr, text); - logChain = logChain - .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); - }); - - child.on("error", (err: Error) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - const errno = (err as NodeJS.ErrnoException).code; - const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; - const msg = - errno === "ENOENT" - ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` - : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; - reject(new Error(msg)); - }); - - child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - void logChain.finally(() => { - resolve({ - exitCode: code, - signal, - timedOut, - stdout, - stderr, + child.stdout?.on("data", (chunk: unknown) => { + const text = String(chunk); + stdout = appendWithCap(stdout, text); + logChain = logChain + .then(() => opts.onLog("stdout", text)) + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); }); - }); - }); + + child.stderr?.on("data", (chunk: unknown) => { + const text = String(chunk); + stderr = appendWithCap(stderr, text); + logChain = logChain + .then(() => opts.onLog("stderr", text)) + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + }); + + child.on("error", (err: Error) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + const errno = (err as NodeJS.ErrnoException).code; + const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; + const msg = + errno === "ENOENT" + ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` + : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; + reject(new Error(msg)); + }); + + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + void logChain.finally(() => { + resolve({ + exitCode: code, + signal, + timedOut, + stdout, + stderr, + }); + }); + }); + }) + .catch(reject); }); } diff --git a/server/package.json b/server/package.json index 5c37c211..8c442e25 100644 --- a/server/package.json +++ b/server/package.json @@ -23,7 +23,7 @@ ], "scripts": { "dev": "tsx src/index.ts", - "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc", "prepack": "pnpm run prepare:ui-dist", @@ -64,6 +64,7 @@ "@types/node": "^24.6.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.18.1", + "cross-env": "^10.1.0", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts index 9814334d..a9201c98 100644 --- a/server/src/__tests__/codex-local-adapter-environment.test.ts +++ b/server/src/__tests__/codex-local-adapter-environment.test.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import { testEnvironment } from "@paperclipai/adapter-codex-local/server"; +const itWindows = process.platform === "win32" ? it : it.skip; + describe("codex_local environment diagnostics", () => { it("creates a missing working directory when cwd is absolute", async () => { const cwd = path.join( @@ -29,4 +31,45 @@ describe("codex_local environment diagnostics", () => { expect(stats.isDirectory()).toBe(true); await fs.rm(path.dirname(cwd), { recursive: true, force: true }); }); + + itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const binDir = path.join(root, "bin"); + const cwd = path.join(root, "workspace"); + const fakeCodex = path.join(binDir, "codex.cmd"); + const script = [ + "@echo off", + "echo {\"type\":\"thread.started\",\"thread_id\":\"test-thread\"}", + "echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}", + "echo {\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}", + "exit /b 0", + "", + ].join("\r\n"); + + try { + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(fakeCodex, script, "utf8"); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { + command: "codex", + cwd, + env: { + OPENAI_API_KEY: "test-key", + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); });