fix(run): refuse a run with ETMUXMISSING when the host has no tmux
Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT` as a 400 — blaming the request for a missing host package, naming no fix, and reaching the user as a raw errno in the console pane. The guard goes in spawnRun, which both spawn paths (POST /api/run and POST /api/lanes/:id/start) already route through, so one check covers them; the message carries the install command. Both routes map the code to 503: the request was fine, the dependency is not installed. The client already renders `error.message` from the body verbatim, so no UI change. Also: README's node advice said "stay on 20/22/24"; 1712 server + 488 client tests are green on v26.8.1, so only 25 is actually the bad one.
This commit is contained in:
@@ -58,6 +58,29 @@ describe("pty-run", () => {
|
||||
assert.ok(newSessionCall.includes("opus"));
|
||||
});
|
||||
|
||||
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
|
||||
// Before this guard the ENOENT escaped from three calls down as
|
||||
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
|
||||
// for a missing host package, with no hint of what to install.
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
const e = new Error("spawnSync tmux ENOENT");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }),
|
||||
(err) => err.code === "ETMUXMISSING" && /install/i.test(err.message),
|
||||
"a missing tmux must be reported as such, with the install command"
|
||||
);
|
||||
assert.ok(
|
||||
!calls.some((c) => c[0] === "new-session"),
|
||||
"must not attempt a session it already knows cannot be created"
|
||||
);
|
||||
});
|
||||
|
||||
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
@@ -118,6 +118,23 @@ describe("/api/run", () => {
|
||||
assert.equal(body.error.code, "EBADCWD");
|
||||
});
|
||||
|
||||
it("POST / answers 503, not 400, when the host has no tmux", async () => {
|
||||
// The request is fine; the dependency is absent. 400 would tell the user to
|
||||
// fix their input, and the body must carry the install line the UI shows.
|
||||
tmux.__setExecImpl(() => {
|
||||
const e = new Error("spawnSync tmux ENOENT");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
});
|
||||
const { status, body } = await fetchJson("/api/run", {
|
||||
method: "POST",
|
||||
body: { laneId: 9, cwd: TMP },
|
||||
});
|
||||
assert.equal(status, 503);
|
||||
assert.equal(body.error.code, "ETMUXMISSING");
|
||||
assert.match(body.error.message, /install/i);
|
||||
});
|
||||
|
||||
it("POST / spawns a tmux session and GET /:id finds it", async () => {
|
||||
let hasSessionCalls = 0;
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
@@ -181,6 +181,21 @@ function spawnRun(args) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
|
||||
// A run IS a tmux session, so an absent binary is a precondition failure, not
|
||||
// a spawn failure. Without this the caller gets `spawnSync tmux ENOENT` from
|
||||
// three directories down — a message that names no fix — and the route maps
|
||||
// it to 400, blaming the request for the host's missing package. Checked here
|
||||
// rather than in each route because both spawn paths (POST /api/run and
|
||||
// POST /api/lanes/:id/start) come through this function.
|
||||
if (!tmux.isTmuxAvailable()) {
|
||||
throw makeErr(
|
||||
"ETMUXMISSING",
|
||||
"tmux is not installed, and every run the dashboard starts is a tmux session. " +
|
||||
"Install it (sudo apt install tmux / brew install tmux; on Windows run AgentHub under WSL2), " +
|
||||
"then restart the dashboard. Everything else keeps working without it."
|
||||
);
|
||||
}
|
||||
|
||||
const id = runIdForLane(laneId);
|
||||
const startedAt = Date.now();
|
||||
const argv = buildArgv({
|
||||
|
||||
@@ -1360,6 +1360,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// A host with no tmux cannot start a run, but nothing is broken on this
|
||||
// side — 503 plus the install line, not a 500 the reader can only read as a
|
||||
// dashboard bug.
|
||||
if (err.code === "ETMUXMISSING") {
|
||||
return res.status(503).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,11 @@ router.post("/", (req, res) => {
|
||||
if (err.code === "ERUNLIVE") {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
// Nor is a host missing tmux: the request was fine, the dependency is not
|
||||
// installed. 400 would tell the user to fix their input.
|
||||
if (err.code === "ETMUXMISSING") {
|
||||
return res.status(503).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code && err.code.startsWith("E")) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user