feat(api): accept a lane tool on create, start and binary probe

This commit is contained in:
2026-08-27 11:00:13 +07:00
parent e8070f04d1
commit ce830094f0
4 changed files with 65 additions and 10 deletions
+20
View File
@@ -988,3 +988,23 @@ exit 1
assert.equal(r.body.checks[0].name, "tests"); 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");
});
});
+11
View File
@@ -174,4 +174,15 @@ describe("/api/run", () => {
assert.equal(status, 200); assert.equal(status, 200);
assert.ok(Array.isArray(body.items)); 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);
});
}); });
+20 -1
View File
@@ -260,6 +260,11 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// path and lane uniqueness stays meaningful. Callers that do care pass one // path and lane uniqueness stays meaningful. Callers that do care pass one
// through unchanged. // through unchanged.
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir(); 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); const owner = lanesLib.resolveLaneByCwd(cwd);
if (owner) { if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane // 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, cwd,
title: body.title || "", title: body.title || "",
...(body.pipeline ? { pipeline: body.pipeline } : {}), ...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
}); });
broadcastLane(lane.id); broadcastLane(lane.id);
return res.status(201).json({ lane: payload(lane), created: true }); return res.status(201).json({ lane: payload(lane), created: true });
} catch (err) { } 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 } }); 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 // 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) => { router.post("/worktree", sameOriginGuard, async (req, res) => {
const body = req.body || {}; 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; const sourceRepo = body.sourceRepo;
if ( if (
typeof sourceRepo !== "string" || typeof sourceRepo !== "string" ||
@@ -706,6 +717,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
base_branch: body.base || null, base_branch: body.base || null,
slug, slug,
...(body.pipeline ? { pipeline: body.pipeline } : {}), ...(body.pipeline ? { pipeline: body.pipeline } : {}),
...(body.tool ? { tool: body.tool } : {}),
}); });
lane = lanesLib.updateLane(lane.id, { status: "provisioning" }); lane = lanesLib.updateLane(lane.id, { status: "provisioning" });
} catch (err) { } catch (err) {
@@ -1230,9 +1242,16 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
laneId: current.id, laneId: current.id,
initialPrompt: body.prompt || "", initialPrompt: body.prompt || "",
cwd: current.cwd, 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, model: body.model,
permissionMode: body.permissionMode, permissionMode: body.permissionMode,
effort: body.effort, effort: body.effort,
sandbox: body.sandbox,
approval: body.approval,
resumeLast: body.resumeLast === true,
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined), resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
}); });
lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" }); lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
+14 -9
View File
@@ -24,6 +24,7 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const runs = require("../lib/pty-run"); const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux"); const tmux = require("../lib/tmux");
const lanesLib = require("../lib/lanes");
const router = Router(); const router = Router();
@@ -236,20 +237,22 @@ router.get("/files", (req, res) => {
res.json({ items: results.slice(0, MAX_RESULTS) }); res.json({ items: results.slice(0, MAX_RESULTS) });
}); });
router.get("/binary", (_req, res) => { router.get("/binary", (req, res) => {
// Surface whether `claude` is on PATH so the UI can show a helpful error // Surface whether the lane's CLI is on PATH so the UI can show a helpful
// before the user clicks Run. We don't actually invoke it — just let the // error before the user clicks Run. We don't actually invoke it. The name is
// user know the spawn will work. // 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( const which = require("node:child_process").spawnSync(
process.platform === "win32" ? "where" : "which", process.platform === "win32" ? "where" : "which",
["claude"], [tool],
{ encoding: "utf8" } { encoding: "utf8" }
); );
const stdout = (which.stdout || "").trim(); const stdout = (which.stdout || "").trim();
res.json({ res.json({ found: which.status === 0 && stdout.length > 0, path: stdout || null });
found: which.status === 0 && stdout.length > 0,
path: stdout || null,
});
}); });
router.get("/tmux", (_req, res) => { 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 } }); return res.status(400).json({ error: { code: err.code, message: err.message } });
} }
try { try {
const lane = lanesLib.getLane(laneId);
const handle = runs.spawnRun({ const handle = runs.spawnRun({
laneId, laneId,
cwd, cwd,
tool: lane?.tool || "claude",
model: typeof body.model === "string" && body.model ? body.model : null, model: typeof body.model === "string" && body.model ? body.model : null,
permissionMode: permissionMode:
typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode) typeof body.permissionMode === "string" && ALLOWED_PERMISSION_MODES.has(body.permissionMode)