Merge pull request #413 from online5880/fix/windows-command-compat
fix: support Windows command wrappers for local adapters
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-runner.mjs watch",
|
"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:once": "node scripts/dev-runner.mjs dev",
|
||||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.30.0",
|
"@changesets/cli": "^2.30.0",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface RunningProcess {
|
|||||||
graceSec: number;
|
graceSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpawnTarget {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
type ChildProcessWithEvents = ChildProcess & {
|
type ChildProcessWithEvents = ChildProcess & {
|
||||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||||
on(
|
on(
|
||||||
@@ -125,6 +130,78 @@ export function defaultPathForPlatform() {
|
|||||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
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<string | null> {
|
||||||
|
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<SpawnTarget> {
|
||||||
|
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 {
|
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;
|
||||||
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) {
|
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
const resolved = await resolveCommandPath(command, cwd, env);
|
||||||
if (hasPathSeparator) {
|
if (resolved) return;
|
||||||
|
if (command.includes("/") || command.includes("\\")) {
|
||||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||||
try {
|
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||||
await fs.access(absolute, fsConstants.X_OK);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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}"`);
|
throw new Error(`Command not found in PATH: "${command}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,78 +273,82 @@ export async function runChildProcess(
|
|||||||
|
|
||||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||||
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
||||||
const child = spawn(command, args, {
|
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||||
cwd: opts.cwd,
|
.then((target) => {
|
||||||
env: mergedEnv,
|
const child = spawn(target.command, target.args, {
|
||||||
shell: false,
|
cwd: opts.cwd,
|
||||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
env: mergedEnv,
|
||||||
}) as ChildProcessWithEvents;
|
shell: false,
|
||||||
|
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||||
|
}) as ChildProcessWithEvents;
|
||||||
|
|
||||||
if (opts.stdin != null && child.stdin) {
|
if (opts.stdin != null && child.stdin) {
|
||||||
child.stdin.write(opts.stdin);
|
child.stdin.write(opts.stdin);
|
||||||
child.stdin.end();
|
child.stdin.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||||
|
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let logChain: Promise<void> = Promise.resolve();
|
let logChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
const timeout =
|
const timeout =
|
||||||
opts.timeoutSec > 0
|
opts.timeoutSec > 0
|
||||||
? setTimeout(() => {
|
? setTimeout(() => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGTERM");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!child.killed) {
|
if (!child.killed) {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
}, Math.max(1, opts.graceSec) * 1000);
|
}, Math.max(1, opts.graceSec) * 1000);
|
||||||
}, opts.timeoutSec * 1000)
|
}, opts.timeoutSec * 1000)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk: unknown) => {
|
child.stdout?.on("data", (chunk: unknown) => {
|
||||||
const text = String(chunk);
|
const text = String(chunk);
|
||||||
stdout = appendWithCap(stdout, text);
|
stdout = appendWithCap(stdout, text);
|
||||||
logChain = logChain
|
logChain = logChain
|
||||||
.then(() => opts.onLog("stdout", text))
|
.then(() => opts.onLog("stdout", text))
|
||||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
.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.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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"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",
|
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"prepack": "pnpm run prepare:ui-dist",
|
"prepack": "pnpm run prepare:ui-dist",
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
|
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
|
||||||
|
|
||||||
|
const itWindows = process.platform === "win32" ? it : it.skip;
|
||||||
|
|
||||||
describe("codex_local environment diagnostics", () => {
|
describe("codex_local environment diagnostics", () => {
|
||||||
it("creates a missing working directory when cwd is absolute", async () => {
|
it("creates a missing working directory when cwd is absolute", async () => {
|
||||||
const cwd = path.join(
|
const cwd = path.join(
|
||||||
@@ -29,4 +31,45 @@ describe("codex_local environment diagnostics", () => {
|
|||||||
expect(stats.isDirectory()).toBe(true);
|
expect(stats.isDirectory()).toBe(true);
|
||||||
await fs.rm(path.dirname(cwd), { recursive: true, force: 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user