diff --git a/src/engines.mjs b/src/engines.mjs index 9c0d3d4c..9a483c4f 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -360,6 +360,38 @@ export const ENGINES = { }, }; +/** + * An explicit path to a build of one engine, from the environment. + * + * `MOSHCODE_ENGINE_BIN_` (for example `MOSHCODE_ENGINE_BIN_CODEX`) names + * the executable to launch for that engine instead of searching for its usual + * name. It exists for the case where you are running your own build of an + * engine — a fork carrying a patch its upstream has not taken, a debug build + * you are bisecting — and want moshcode to open *that* rather than whichever + * copy happens to come first on PATH. + * + * It is unset by default, and deliberately so: with no override the name below + * is resolved against PATH exactly as before, which is the behaviour every + * engine here documents and every test relies on. An override is a statement + * that you know better than PATH for this one engine, so it is only ever made + * out loud. + * + * The value is used as-is, including `~`-free absolute paths; an empty or + * whitespace-only value is treated as unset rather than as an empty command. + */ +export function engineBinOverride(key, env = process.env) { + const value = env[`MOSHCODE_ENGINE_BIN_${key.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +// Applied once, here, rather than at each launch site: `bin` is read directly +// by the CLI, the TUI's error messages, the swarm and the MCP bridge, and an +// override that only some of them honoured would be worse than none. +for (const [key, engine] of Object.entries(ENGINES)) { + const override = engineBinOverride(key); + if (override) engine.bin = override; +} + /** * The command that upgrades an already-installed engine in place: its native * updater if it has one, else re-run the installer (they're idempotent and diff --git a/test/engines.test.mjs b/test/engines.test.mjs index daf32a67..e6cd7406 100644 --- a/test/engines.test.mjs +++ b/test/engines.test.mjs @@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import test from "node:test"; -import { ENGINES, agentLaunchArgs, aiExecArgs, exitReason, isInstalled, openPassthrough, pickAiEngine, ranOk, resolveEngine, runCmd } from "../src/engines.mjs"; +import { ENGINES, agentLaunchArgs, aiExecArgs, engineBinOverride, exitReason, isInstalled, openPassthrough, pickAiEngine, ranOk, resolveEngine, runCmd } from "../src/engines.mjs"; const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); // The autonomous-session bypass flags each engine declares (engine.agentArgs). @@ -62,7 +62,7 @@ process.stdout.write(JSON.stringify(process.argv.slice(2))); chmodSync(file, 0o755); } -function run(args, binDir) { +function run(args, binDir, extraEnv = {}) { return new Promise((resolve, reject) => { const ioDir = tempDir("moshcode-engine-stdio-"); const stdinFile = path.join(ioDir, "stdin"); @@ -77,6 +77,7 @@ function run(args, binDir) { env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, + ...extraEnv, }, }); let failed = false; @@ -274,3 +275,37 @@ test("PATH still wins over a tool's install dir", async () => { process.env.PATH = previous; } }); + +test("an engine bin override is read from the environment, and only when it says something", () => { + assert.deepEqual( + [ + engineBinOverride("codex", {}), + engineBinOverride("codex", { MOSHCODE_ENGINE_BIN_CODEX: "" }), + engineBinOverride("codex", { MOSHCODE_ENGINE_BIN_CODEX: " " }), + engineBinOverride("codex", { MOSHCODE_ENGINE_BIN_CODEX: " /opt/codex " }), + engineBinOverride("privacycode", { MOSHCODE_ENGINE_BIN_PRIVACYCODE: "/opt/pc" }), + ], + [null, null, null, "/opt/codex", "/opt/pc"], + ); +}); + +test("an engine bin override launches that build instead of the copy on PATH", async () => { + // The name every other test would get: first on PATH, and not what we asked for. + const pathDir = tempDir(); + mkdirSync(pathDir, { recursive: true }); + writeEngine(pathDir, "codex"); + + // Our own build, deliberately somewhere PATH would never find it. + const buildDir = tempDir("moshcode-engine-build-"); + mkdirSync(buildDir, { recursive: true }); + const build = path.join(buildDir, "codex-fork"); + writeFileSync(build, `#!/usr/bin/env node +process.stdout.write(JSON.stringify(["override", ...process.argv.slice(2)])); +`); + chmodSync(build, 0o755); + + const result = await run(["agents", "codex"], pathDir, { MOSHCODE_ENGINE_BIN_CODEX: build }); + + assert.equal(result.status, 0); + assert.deepEqual(JSON.parse(result.stdout), ["override", ...EXPECTED_LAUNCH_ARGS.codex]); +});