342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { resolveClaudeCodeExecutable } from "../src/acp/agent-command.js";
|
|
import { resolveAgentSessionCwd } from "../src/acp/client-process.js";
|
|
import { buildAgentSpawnOptions, buildSpawnCommandOptions } from "../src/acp/client.js";
|
|
import { buildTerminalSpawnOptions } from "../src/acp/terminal-manager.js";
|
|
import { buildQueueOwnerSpawnOptions } from "../src/cli/session/queue-owner-process.js";
|
|
|
|
test("buildAgentSpawnOptions hides Windows console windows and preserves auth env", () => {
|
|
const options = buildAgentSpawnOptions("/tmp/acpx-agent", {
|
|
ACPX_AUTH_TOKEN: "secret-token",
|
|
});
|
|
|
|
assert.equal(options.cwd, "/tmp/acpx-agent");
|
|
assert.deepEqual(options.stdio, ["pipe", "pipe", "pipe"]);
|
|
assert.equal(options.windowsHide, true);
|
|
assert.equal(options.env.ACPX_AUTH_TOKEN, "secret-token");
|
|
});
|
|
|
|
test("buildAgentSpawnOptions promotes explicit ACPX auth env vars into agent auth env", () => {
|
|
const previousPrefixed = process.env.ACPX_AUTH_OPENAI_API_KEY;
|
|
const previousNormalized = process.env.OPENAI_API_KEY;
|
|
|
|
process.env.ACPX_AUTH_OPENAI_API_KEY = "sk-explicit";
|
|
delete process.env.OPENAI_API_KEY;
|
|
|
|
try {
|
|
const options = buildAgentSpawnOptions("/tmp/acpx-agent", undefined);
|
|
assert.equal(options.env.ACPX_AUTH_OPENAI_API_KEY, "sk-explicit");
|
|
assert.equal(options.env.OPENAI_API_KEY, "sk-explicit");
|
|
} finally {
|
|
if (previousPrefixed == null) {
|
|
delete process.env.ACPX_AUTH_OPENAI_API_KEY;
|
|
} else {
|
|
process.env.ACPX_AUTH_OPENAI_API_KEY = previousPrefixed;
|
|
}
|
|
|
|
if (previousNormalized == null) {
|
|
delete process.env.OPENAI_API_KEY;
|
|
} else {
|
|
process.env.OPENAI_API_KEY = previousNormalized;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("buildTerminalSpawnOptions hides Windows console windows and maps env entries", () => {
|
|
const options = buildTerminalSpawnOptions("node", "/tmp/acpx-terminal", [
|
|
{ name: "TMUX", value: "/tmp/tmux-1000/default,123,0" },
|
|
{ name: "TERM", value: "screen-256color" },
|
|
]);
|
|
|
|
assert.equal(options.cwd, "/tmp/acpx-terminal");
|
|
assert.deepEqual(options.stdio, ["ignore", "pipe", "pipe"]);
|
|
assert.equal(options.windowsHide, true);
|
|
assert.equal(options.env?.TMUX, "/tmp/tmux-1000/default,123,0");
|
|
assert.equal(options.env?.TERM, "screen-256color");
|
|
});
|
|
|
|
test("buildQueueOwnerSpawnOptions hides Windows console windows and passes payload", () => {
|
|
const options = buildQueueOwnerSpawnOptions('{"sessionId":"queue-session"}');
|
|
|
|
assert.equal(options.detached, true);
|
|
assert.equal(options.stdio, "ignore");
|
|
assert.equal(options.windowsHide, true);
|
|
assert.equal(options.env.ACPX_QUEUE_OWNER_PAYLOAD, '{"sessionId":"queue-session"}');
|
|
});
|
|
|
|
test("buildSpawnCommandOptions enables shell for .cmd/.bat on Windows", () => {
|
|
const base = {
|
|
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
};
|
|
|
|
const cmdOptions = buildSpawnCommandOptions("C:\\Program Files\\nodejs\\npx.cmd", base, "win32");
|
|
const batOptions = buildSpawnCommandOptions("C:\\tools\\agent.bat", base, "win32");
|
|
|
|
assert.equal(cmdOptions.shell, true);
|
|
assert.equal(batOptions.shell, true);
|
|
assert.deepEqual(cmdOptions.stdio, base.stdio);
|
|
assert.equal(cmdOptions.windowsHide, true);
|
|
});
|
|
|
|
test("buildSpawnCommandOptions enables shell for PATH-resolved .cmd wrappers on Windows", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-"));
|
|
const env = {
|
|
PATH: tempDir,
|
|
PATHEXT: ".COM;.EXE;.BAT;.CMD",
|
|
};
|
|
const base = {
|
|
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
};
|
|
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "npx.cmd"), "@echo off\r\n");
|
|
|
|
const options = buildSpawnCommandOptions("npx", base, "win32", env);
|
|
assert.equal(options.shell, true);
|
|
assert.deepEqual(options.stdio, base.stdio);
|
|
assert.equal(options.windowsHide, true);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildSpawnCommandOptions keeps shell disabled for non-batch commands", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-"));
|
|
const env = {
|
|
PATH: tempDir,
|
|
PATHEXT: ".COM;.EXE;.BAT;.CMD",
|
|
};
|
|
const base = {
|
|
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
};
|
|
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "node.exe"), "");
|
|
|
|
const linuxOptions = buildSpawnCommandOptions("/usr/bin/npx", base, "linux");
|
|
const windowsExeOptions = buildSpawnCommandOptions("node", base, "win32", env);
|
|
|
|
assert.equal(linuxOptions.shell, undefined);
|
|
assert.equal(windowsExeOptions.shell, undefined);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resolveAgentSessionCwd translates WSL cwd for Windows exe agents", async () => {
|
|
let capturedCwd: string | undefined;
|
|
const inputCwd = "/home/user/project";
|
|
const resolvedCwd = path.resolve(inputCwd);
|
|
|
|
const cwd = await resolveAgentSessionCwd(
|
|
inputCwd,
|
|
'"/mnt/c/Users/User/AppData/Local/GitHub CLI/copilot/copilot.exe" --acp --stdio',
|
|
{
|
|
platform: "linux",
|
|
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
|
|
runWslpath: async (value) => {
|
|
capturedCwd = value;
|
|
return "\\\\wsl.localhost\\Ubuntu\\home\\user\\project\n";
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.equal(capturedCwd, resolvedCwd);
|
|
assert.equal(cwd, "\\\\wsl.localhost\\Ubuntu\\home\\user\\project");
|
|
});
|
|
|
|
test("resolveAgentSessionCwd leaves non-WSL and non-Windows agents on resolved cwd", async () => {
|
|
const nonWsl = await resolveAgentSessionCwd("relative/project", "/mnt/c/tools/copilot.exe", {
|
|
platform: "linux",
|
|
existsSync: () => false,
|
|
runWslpath: async () => {
|
|
throw new Error("wslpath should not run");
|
|
},
|
|
});
|
|
const inputCwd = "/home/user/project";
|
|
const wslNodeAgent = await resolveAgentSessionCwd(inputCwd, "node ./agent.js", {
|
|
platform: "linux",
|
|
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
|
|
runWslpath: async () => {
|
|
throw new Error("wslpath should not run");
|
|
},
|
|
});
|
|
|
|
assert.equal(nonWsl, path.resolve("relative/project"));
|
|
assert.equal(wslNodeAgent, path.resolve(inputCwd));
|
|
});
|
|
|
|
test("resolveAgentSessionCwd translates WSL cwd for Windows .cmd wrappers", async () => {
|
|
let capturedCwd: string | undefined;
|
|
const inputCwd = "/home/user/project";
|
|
const resolvedCwd = path.resolve(inputCwd);
|
|
|
|
const cwd = await resolveAgentSessionCwd(
|
|
inputCwd,
|
|
'"/mnt/c/Program Files/nodejs/npx.cmd" some-acp-agent --stdio',
|
|
{
|
|
platform: "linux",
|
|
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
|
|
runWslpath: async (value) => {
|
|
capturedCwd = value;
|
|
return "\\\\wsl.localhost\\Ubuntu\\home\\user\\project\n";
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.equal(capturedCwd, resolvedCwd);
|
|
assert.equal(cwd, "\\\\wsl.localhost\\Ubuntu\\home\\user\\project");
|
|
});
|
|
|
|
test("resolveAgentSessionCwd translates WSL cwd for Windows agents on non-C drives", async () => {
|
|
let capturedCwd: string | undefined;
|
|
const inputCwd = "/home/user/project";
|
|
const resolvedCwd = path.resolve(inputCwd);
|
|
|
|
const cwd = await resolveAgentSessionCwd(inputCwd, "/mnt/d/tools/agent.bat --acp", {
|
|
platform: "linux",
|
|
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
|
|
runWslpath: async (value) => {
|
|
capturedCwd = value;
|
|
return "\\\\wsl.localhost\\Ubuntu\\home\\user\\project\n";
|
|
},
|
|
});
|
|
|
|
assert.equal(capturedCwd, resolvedCwd);
|
|
assert.equal(cwd, "\\\\wsl.localhost\\Ubuntu\\home\\user\\project");
|
|
});
|
|
|
|
test("resolveAgentSessionCwd does not translate WSL cwd for extension-less commands under /mnt/<drive>/", async () => {
|
|
const inputCwd = "/home/user/project";
|
|
const cwd = await resolveAgentSessionCwd(inputCwd, "/mnt/c/tools/linux-agent --acp", {
|
|
platform: "linux",
|
|
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
|
|
runWslpath: async () => {
|
|
throw new Error("wslpath should not run for extension-less /mnt/<drive>/ commands");
|
|
},
|
|
});
|
|
|
|
assert.equal(cwd, path.resolve(inputCwd));
|
|
});
|
|
|
|
test("resolveAgentSessionCwd rejects empty wslpath output", async () => {
|
|
await assert.rejects(
|
|
resolveAgentSessionCwd("/home/user/project", "/mnt/c/tools/copilot.exe --acp", {
|
|
platform: "linux",
|
|
existsSync: (filePath) => filePath === "/proc/sys/fs/binfmt_misc/WSLInterop",
|
|
runWslpath: async () => "\n",
|
|
}),
|
|
/wslpath returned an empty Windows path/,
|
|
);
|
|
});
|
|
|
|
test("buildTerminalSpawnOptions enables shell for PATH-resolved .cmd wrappers on Windows", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-"));
|
|
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "npx.cmd"), "@echo off\r\n");
|
|
|
|
const options = buildTerminalSpawnOptions(
|
|
"npx",
|
|
"/tmp/acpx-terminal",
|
|
[
|
|
{ name: "PATH", value: tempDir },
|
|
{ name: "PATHEXT", value: ".COM;.EXE;.BAT;.CMD" },
|
|
],
|
|
"win32",
|
|
);
|
|
|
|
assert.equal(options.shell, true);
|
|
assert.deepEqual(options.stdio, ["ignore", "pipe", "pipe"]);
|
|
assert.equal(options.windowsHide, true);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("buildTerminalSpawnOptions keeps shell disabled for non-batch commands", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-"));
|
|
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "node.exe"), "");
|
|
|
|
const options = buildTerminalSpawnOptions(
|
|
"node",
|
|
"/tmp/acpx-terminal",
|
|
[
|
|
{ name: "PATH", value: tempDir },
|
|
{ name: "PATHEXT", value: ".COM;.EXE;.BAT;.CMD" },
|
|
],
|
|
"win32",
|
|
);
|
|
|
|
assert.equal(options.shell, undefined);
|
|
assert.deepEqual(options.stdio, ["ignore", "pipe", "pipe"]);
|
|
assert.equal(options.windowsHide, true);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resolveClaudeCodeExecutable finds claude.exe on PATH on Windows", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-"));
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "claude.exe"), "");
|
|
const env = { PATH: tempDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" } as NodeJS.ProcessEnv;
|
|
const result = resolveClaudeCodeExecutable("win32", env);
|
|
assert.equal(result, path.join(tempDir, "claude.exe"));
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resolveClaudeCodeExecutable returns undefined when CLAUDE_CODE_EXECUTABLE is already set", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-"));
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "claude.exe"), "");
|
|
const env = {
|
|
PATH: tempDir,
|
|
PATHEXT: ".COM;.EXE;.BAT;.CMD",
|
|
CLAUDE_CODE_EXECUTABLE: "/custom/claude",
|
|
} as NodeJS.ProcessEnv;
|
|
const result = resolveClaudeCodeExecutable("win32", env);
|
|
assert.equal(result, undefined);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resolveClaudeCodeExecutable respects case-insensitive env var on Windows", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-claude-exe-"));
|
|
try {
|
|
await fs.writeFile(path.join(tempDir, "claude.exe"), "");
|
|
const env = {
|
|
PATH: tempDir,
|
|
PATHEXT: ".COM;.EXE;.BAT;.CMD",
|
|
claude_code_executable: "/custom/claude",
|
|
} as NodeJS.ProcessEnv;
|
|
const result = resolveClaudeCodeExecutable("win32", env);
|
|
assert.equal(result, undefined);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resolveClaudeCodeExecutable returns undefined on non-Windows platforms", () => {
|
|
const result = resolveClaudeCodeExecutable("linux", { PATH: "/usr/bin" } as NodeJS.ProcessEnv);
|
|
assert.equal(result, undefined);
|
|
});
|
|
|
|
test("resolveClaudeCodeExecutable returns undefined when claude is not on PATH", () => {
|
|
const env = { PATH: "/nonexistent", PATHEXT: ".COM;.EXE;.BAT;.CMD" } as NodeJS.ProcessEnv;
|
|
const result = resolveClaudeCodeExecutable("win32", env);
|
|
assert.equal(result, undefined);
|
|
});
|