diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index e7471faba..00c0148b0 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -1,10 +1,18 @@ import { execFile } from "node:child_process"; -import { accessSync, constants, existsSync } from "node:fs"; +import { + accessSync, + constants, + copyFileSync, + existsSync, + mkdtempSync, + rmSync, + symlinkSync, +} from "node:fs"; import { copyFile, cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; -import { inject } from "vitest"; +import { afterAll, inject } from "vitest"; import { InitUseCase } from "../../src/contexts/framework/application/init-use-case.js"; import { CLIOutput } from "../../src/presentation/output.js"; import { environmentWithoutGitVariables as withoutGitEnv } from "../../src/runtime/git/git-environment.js"; @@ -84,7 +92,26 @@ export async function createTestEnv(prefix: string): Promise<{ * A sandboxed run must reach none of these: the CLI registers marketplaces through a tool's * own command when its binary is there, making recorded output depend on the machine. */ -const DRIVABLE_TOOL_BINARIES = ["claude", "codex", "copilot", "cursor-agent"]; +const DRIVABLE_TOOL_BINARIES = ["opencode", "claude", "codex", "copilot", "cursor-agent"]; + +let isolatedNodeDir: string | undefined; +afterAll(() => { + if (isolatedNodeDir) rmSync(isolatedNodeDir, { recursive: true, force: true }); + isolatedNodeDir = undefined; +}); + +/** Keep node reachable without admitting the global AI tools installed beside it. */ +function sandboxNodeDir(nodeDir: string): string { + if (withoutDrivableToolBinary(nodeDir) && !hasExecutable(nodeDir, "aidd")) return nodeDir; + if (!isolatedNodeDir) { + isolatedNodeDir = mkdtempSync(join(tmpdir(), "aidd-e2e-node-")); + const target = join(isolatedNodeDir, process.platform === "win32" ? "node.exe" : "node"); + // Windows file symlinks require privileges a CI runner need not have. + if (process.platform === "win32") copyFileSync(process.execPath, target); + else symlinkSync(process.execPath, target); + } + return isolatedNodeDir; +} /** * Judged by what a directory holds, never by a keep-list: `node` and `copilot` share @@ -159,10 +186,10 @@ export function pathDirsWithoutAidd({ * Narrow by construction, then filtered of any directory holding a drivable tool binary — * without the filter a tool shipped into `/usr/bin` stays reachable. */ -export function pathWithoutAidd(): string { +export function pathWithoutAidd(nodeDir = dirname(process.execPath)): string { return pathDirsWithoutAidd({ platform: process.platform, - nodeDir: dirname(process.execPath), + nodeDir: sandboxNodeDir(nodeDir), gitDir: findGitDirWithoutAidd(), systemRoot: process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows", pathDirs: (process.env.PATH ?? "").split(delimiter).filter(Boolean), diff --git a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts index b53b30983..67c141938 100644 --- a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts +++ b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts @@ -1,8 +1,9 @@ import { execFile } from "node:child_process"; -import { delimiter } from "node:path"; +import { copyFile, mkdir, symlink, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { createTestEnv, sandboxedEnv } from "./helpers.js"; +import { createTestEnv, pathWithoutAidd, sandboxedEnv } from "./helpers.js"; const execFileAsync = promisify(execFile); @@ -23,6 +24,30 @@ async function whichUnderSandbox(binary: string, cwd: string, env: NodeJS.Proces } describe("E2E: the sandbox a test spawns into", () => { + it("keeps node executable when an AI tool is installed beside it", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("sandbox-shared-node-"); + try { + const sharedDir = join(tempDir, "bin"); + await mkdir(sharedDir); + const nodePath = join(sharedDir, process.platform === "win32" ? "node.exe" : "node"); + if (process.platform === "win32") await copyFile(process.execPath, nodePath); + else await symlink(process.execPath, nodePath); + await writeFile(join(sharedDir, "codex"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + const path = pathWithoutAidd(sharedDir); + const env = { ...sandboxedEnv(fakeHome), PATH: path, Path: path }; + expect(path.split(delimiter)).not.toContain(sharedDir); + expect(await whichUnderSandbox("codex", projectDir, env)).toBe(""); + const { stdout } = await execFileAsync("node", ["-p", "process.version"], { + cwd: projectDir, + env, + }); + expect(stdout.trim()).toBe(process.version); + } finally { + await cleanup(); + } + }); + it("reaches no AI tool binary, whatever the runner has installed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("sandbox-path-"); try {