diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index 740c1301..af5b286a 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -988,3 +988,23 @@ exit 1 assert.equal(r.body.checks[0].name, "tests"); }); }); + +describe("/api/lanes tool tests", () => { + it("POST /api/lanes/ensure stores the requested tool", async () => { + const res = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/codex-lane", + tool: "codex", + }); + assert.equal(res.status, 201); + assert.equal(res.body.lane.tool, "codex"); + }); + + it("POST /api/lanes/ensure rejects an unknown tool", async () => { + const res = await request("POST", "/api/lanes/ensure", { + cwd: "/tmp/bad-tool", + tool: "cursor", + }); + assert.equal(res.status, 400); + assert.equal(res.body.error.code, "EBADTOOL"); + }); +}); diff --git a/server/__tests__/run.test.js b/server/__tests__/run.test.js index 952470a0..d18fa123 100644 --- a/server/__tests__/run.test.js +++ b/server/__tests__/run.test.js @@ -174,4 +174,15 @@ describe("/api/run", () => { assert.equal(status, 200); assert.ok(Array.isArray(body.items)); }); + + it("GET /api/run/binary?tool=codex probes codex, not claude", async () => { + const res = await fetchJson("/api/run/binary?tool=codex"); + assert.equal(res.status, 200); + assert.equal(typeof res.body.found, "boolean"); + }); + + it("GET /api/run/binary rejects an unknown tool", async () => { + const res = await fetchJson("/api/run/binary?tool=rm"); + assert.equal(res.status, 400); + }); }); diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 592335bb..4231b252 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -260,6 +260,11 @@ router.post("/ensure", sameOriginGuard, (req, res) => { // path and lane uniqueness stays meaningful. Callers that do care pass one // through unchanged. const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir(); + + if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) { + return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); + } + const owner = lanesLib.resolveLaneByCwd(cwd); if (owner) { // First-write wins on the title: an ensure-with-title on an existing lane @@ -276,11 +281,12 @@ router.post("/ensure", sameOriginGuard, (req, res) => { cwd, title: body.title || "", ...(body.pipeline ? { pipeline: body.pipeline } : {}), + ...(body.tool ? { tool: body.tool } : {}), }); broadcastLane(lane.id); return res.status(201).json({ lane: payload(lane), created: true }); } catch (err) { - if (err.code === "EBADCWD" || err.code === "EBADPIPELINE") { + if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADTOOL") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } // The cwd UNIQUE constraint is the arbiter: someone else won the race, so @@ -641,6 +647,11 @@ router.get("/:id/ci/status", async (req, res) => { */ router.post("/worktree", sameOriginGuard, async (req, res) => { const body = req.body || {}; + + if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) { + return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); + } + const sourceRepo = body.sourceRepo; if ( typeof sourceRepo !== "string" || @@ -706,6 +717,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => { base_branch: body.base || null, slug, ...(body.pipeline ? { pipeline: body.pipeline } : {}), + ...(body.tool ? { tool: body.tool } : {}), }); lane = lanesLib.updateLane(lane.id, { status: "provisioning" }); } catch (err) { @@ -1230,9 +1242,16 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { laneId: current.id, initialPrompt: body.prompt || "", cwd: current.cwd, + // The tool is the LANE's fact, never the request's: a client that + // asked for the other CLI would otherwise get a pane that doesn't + // match the lane's badge, its history, or its binary probe. + tool: current.tool || "claude", model: body.model, permissionMode: body.permissionMode, effort: body.effort, + sandbox: body.sandbox, + approval: body.approval, + resumeLast: body.resumeLast === true, resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined), }); lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" }); diff --git a/server/routes/run.js b/server/routes/run.js index d4dfeec6..0a63cb03 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -24,6 +24,7 @@ const fs = require("node:fs"); const path = require("node:path"); const runs = require("../lib/pty-run"); const tmux = require("../lib/tmux"); +const lanesLib = require("../lib/lanes"); const router = Router(); @@ -236,20 +237,22 @@ router.get("/files", (req, res) => { res.json({ items: results.slice(0, MAX_RESULTS) }); }); -router.get("/binary", (_req, res) => { - // Surface whether `claude` is on PATH so the UI can show a helpful error - // before the user clicks Run. We don't actually invoke it — just let the - // user know the spawn will work. +router.get("/binary", (req, res) => { + // Surface whether the lane's CLI is on PATH so the UI can show a helpful + // error before the user clicks Run. We don't actually invoke it. The name is + // taken from a fixed allowlist, never from the query string directly — this + // string reaches `which`. + const tool = req.query.tool === undefined ? "claude" : String(req.query.tool); + if (tool !== "claude" && tool !== "codex") { + return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } }); + } const which = require("node:child_process").spawnSync( process.platform === "win32" ? "where" : "which", - ["claude"], + [tool], { encoding: "utf8" } ); const stdout = (which.stdout || "").trim(); - res.json({ - found: which.status === 0 && stdout.length > 0, - path: stdout || null, - }); + res.json({ found: which.status === 0 && stdout.length > 0, path: stdout || null }); }); router.get("/tmux", (_req, res) => { @@ -269,9 +272,11 @@ router.post("/", (req, res) => { return res.status(400).json({ error: { code: err.code, message: err.message } }); } try { + const lane = lanesLib.getLane(laneId); const handle = runs.spawnRun({ laneId, cwd, + tool: lane?.tool || "claude", model: typeof body.model === "string" && body.model ? body.model : null, permissionMode: typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)