feat(ai-tools): catalog of AI coding CLIs, detection, enable/default, lanes for any tool (#40 A1)
server/lib/ai-tools.js loads server/data/ai-tools/*.json (38 tools, data from Orca, MIT) plus DASHBOARD_AI_TOOLS_DIR, detects each CLI by walking PATH, and keeps enable/default in <data dir>/ai-tools.json. shell-path.js merges the login shell's PATH at startup and on Refresh. /api/ai-tools lists, refreshes and toggles; /api/run/binary resolves through the catalog. Lanes accept any catalog id (EDISABLED for a disabled one on creation, ENOTOOL at start once a tool left the catalog). pty-run keeps the Claude/Codex builders and builds every other tool's argv from its catalog file, pasting an after-start prompt through a tmux buffer; every pane gets the hydrated PATH. A per-run bypass switch appends the catalog's bypass args (Codex included). POST /api/run now forwards sandbox/approval/resumeLast, which it dropped. agenthub lanes add gains --tool.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file Route tests for /api/ai-tools: list shape, enable/disable, default
|
||||
* errors, the same-origin guard, and /api/run/binary resolving through the
|
||||
* catalog.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-route-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
const BIN = path.join(ROOT, "bin");
|
||||
fs.mkdirSync(BIN);
|
||||
for (const name of ["claude", "agy"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
const origPath = process.env.PATH;
|
||||
process.env.PATH = BIN;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
require("../lib/ai-tools").refresh();
|
||||
|
||||
let app;
|
||||
let base;
|
||||
const request = (method, p, body, headers = {}) =>
|
||||
fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
before(async () => {
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
process.env.PATH = origPath;
|
||||
await new Promise((r) => app.close(r));
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("/api/ai-tools", () => {
|
||||
it("lists the catalog with detection and the default", async () => {
|
||||
const { status, body } = await request("GET", "/api/ai-tools");
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.default, "claude");
|
||||
const agy = body.tools.find((t) => t.id === "antigravity");
|
||||
assert.equal(agy.installed, true);
|
||||
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||
assert.equal(agy.resume, true);
|
||||
assert.equal(agy.bypass, true);
|
||||
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
|
||||
assert.equal("args" in agy, false);
|
||||
});
|
||||
|
||||
it("disables and re-enables a tool, and sets the default", async () => {
|
||||
let r = await request("PUT", "/api/ai-tools/claude", { enabled: false });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.tools.find((t) => t.id === "claude").enabled, false);
|
||||
assert.equal(r.body.default, "antigravity");
|
||||
r = await request("PUT", "/api/ai-tools/claude", { enabled: true });
|
||||
assert.equal(r.body.default, "claude");
|
||||
r = await request("PUT", "/api/ai-tools/antigravity", { default: true });
|
||||
assert.equal(r.body.default, "antigravity");
|
||||
});
|
||||
|
||||
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"PUT",
|
||||
"/api/ai-tools/claude",
|
||||
{ enabled: false },
|
||||
{ Origin: "https://evil.test" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("refresh re-detects", async () => {
|
||||
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
|
||||
const r = await request("POST", "/api/ai-tools/refresh");
|
||||
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
|
||||
});
|
||||
|
||||
it("/api/run/binary resolves through the catalog", async () => {
|
||||
const r = await request("GET", "/api/run/binary?tool=antigravity");
|
||||
assert.deepEqual(r.body, { found: true, path: path.join(BIN, "agy") });
|
||||
assert.equal((await request("GET", "/api/run/binary?tool=rm")).status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lanes on catalog tools", () => {
|
||||
it("creates a lane for an installed or not-installed tool, refuses unknown/disabled", async () => {
|
||||
const dir = (n) => {
|
||||
const d = path.join(ROOT, n);
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
};
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.lane.tool, "gemini");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "aider" });
|
||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||
assert.equal(r.body.error.code, "EBADTOOL");
|
||||
await request("PUT", "/api/ai-tools/aider", { enabled: false });
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "aider" });
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EDISABLED");
|
||||
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "aider" });
|
||||
assert.equal(r.body.error.code, "EDISABLED");
|
||||
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "aider" });
|
||||
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
|
||||
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "aider" });
|
||||
assert.equal(r.body.error.code, "EDISABLED");
|
||||
await request("PUT", "/api/ai-tools/aider", { enabled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tools.js — catalog validation and override,
|
||||
* PATH detection over fake executables, and the enable/default state rules.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-"));
|
||||
const builtin = path.join(root, "builtin");
|
||||
const custom = path.join(root, "custom");
|
||||
const bin = path.join(root, "bin");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [builtin, custom, bin, data]) fs.mkdirSync(d);
|
||||
|
||||
const origPath = process.env.PATH;
|
||||
const origData = process.env.DASHBOARD_DATA_DIR;
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
|
||||
const write = (dir, name, doc) =>
|
||||
fs.writeFileSync(path.join(dir, name), typeof doc === "string" ? doc : JSON.stringify(doc));
|
||||
const exe = (name, mode = 0o755) => {
|
||||
const p = path.join(bin, name);
|
||||
fs.writeFileSync(p, "#!/bin/sh\n");
|
||||
fs.chmodSync(p, mode);
|
||||
return p;
|
||||
};
|
||||
|
||||
before(() => {
|
||||
write(builtin, "claude.json", {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
detect: ["claude"],
|
||||
launcher: "claude",
|
||||
});
|
||||
write(builtin, "codex.json", {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
detect: ["codex"],
|
||||
launcher: "codex",
|
||||
});
|
||||
write(builtin, "zed.json", {
|
||||
id: "zed",
|
||||
label: "Aaa Zed",
|
||||
detect: ["zed"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "pi.json", {
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
detect: ["pi"],
|
||||
prompt: { mode: "argv" },
|
||||
resume: ["--continue"],
|
||||
});
|
||||
write(builtin, "needs.json", {
|
||||
id: "needs",
|
||||
label: "Needs",
|
||||
detect: ["needs"],
|
||||
requires: ["helper"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "bad-id.json", {
|
||||
id: "Bad_Id",
|
||||
label: "x",
|
||||
detect: ["x"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "slash.json", {
|
||||
id: "slash",
|
||||
label: "x",
|
||||
detect: ["/bin/sh"],
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "args.json", {
|
||||
id: "args",
|
||||
label: "x",
|
||||
detect: ["x"],
|
||||
args: "--oops",
|
||||
prompt: { mode: "argv" },
|
||||
});
|
||||
write(builtin, "mode.json", { id: "mode", label: "x", detect: ["x"], prompt: { mode: "shell" } });
|
||||
write(builtin, "broken.json", "{not json");
|
||||
write(custom, "pi.json", {
|
||||
id: "pi",
|
||||
label: "Pi Custom",
|
||||
detect: ["pi"],
|
||||
prompt: { mode: "flag", flag: "-p" },
|
||||
});
|
||||
write(custom, "evil.json", { id: "evil", label: "x", detect: ["x"], launcher: "claude" });
|
||||
exe("claude");
|
||||
exe("pi");
|
||||
exe("needs");
|
||||
exe("zed", 0o644); // present but not executable
|
||||
process.env.PATH = bin;
|
||||
aiTools.__setCatalogDirs([builtin, custom]);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.env.PATH = origPath;
|
||||
if (origData === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = origData;
|
||||
aiTools.__setCatalogDirs(null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(data, "ai-tools.json"), { force: true });
|
||||
aiTools.refresh();
|
||||
});
|
||||
|
||||
describe("catalog", () => {
|
||||
it("skips invalid files and keeps valid ones in catalog order", () => {
|
||||
assert.deepEqual(
|
||||
aiTools.list().map((t) => t.id),
|
||||
["claude", "codex", "zed", "needs", "pi"]
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a custom file replace a built-in one, but never set launcher", () => {
|
||||
assert.equal(aiTools.get("pi").label, "Pi Custom");
|
||||
assert.equal(aiTools.get("pi").source, "custom");
|
||||
assert.equal(aiTools.get("pi").resume, null);
|
||||
assert.equal(aiTools.has("evil"), false);
|
||||
assert.equal(aiTools.get("claude").source, "builtin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detection", () => {
|
||||
it("finds executables on PATH and ignores non-executable files", () => {
|
||||
assert.deepEqual(aiTools.detect("claude"), {
|
||||
installed: true,
|
||||
path: path.join(bin, "claude"),
|
||||
command: "claude",
|
||||
});
|
||||
assert.equal(aiTools.detect("zed").installed, false);
|
||||
assert.equal(aiTools.detect("codex").installed, false);
|
||||
});
|
||||
|
||||
it("needs every `requires` command too", () => {
|
||||
assert.equal(aiTools.detect("needs").installed, false);
|
||||
exe("helper");
|
||||
aiTools.refresh();
|
||||
assert.equal(aiTools.detect("needs").installed, true);
|
||||
fs.rmSync(path.join(bin, "helper"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable / default", () => {
|
||||
it("defaults to claude, then the first installed enabled tool", () => {
|
||||
assert.equal(aiTools.effectiveDefault(), "claude");
|
||||
aiTools.setEnabled("claude", false);
|
||||
assert.equal(aiTools.effectiveDefault(), "pi");
|
||||
});
|
||||
|
||||
it("stores a default and clears it when that tool is disabled", () => {
|
||||
aiTools.setDefault("pi");
|
||||
assert.equal(aiTools.readState().default, "pi");
|
||||
assert.equal(aiTools.effectiveDefault(), "pi");
|
||||
aiTools.setEnabled("pi", false);
|
||||
assert.equal(aiTools.readState().default, null);
|
||||
assert.equal(aiTools.isEnabled("pi"), false);
|
||||
aiTools.setEnabled("pi", true);
|
||||
assert.equal(aiTools.isEnabled("pi"), true);
|
||||
});
|
||||
|
||||
it("refuses a default that is unknown, not installed or disabled", () => {
|
||||
assert.throws(() => aiTools.setDefault("nope"), { code: "ENOTOOL" });
|
||||
assert.throws(() => aiTools.setDefault("codex"), { code: "EBADDEFAULT" });
|
||||
aiTools.setEnabled("pi", false);
|
||||
assert.throws(() => aiTools.setDefault("pi"), { code: "EBADDEFAULT" });
|
||||
assert.throws(() => aiTools.setEnabled("nope", false), { code: "ENOTOOL" });
|
||||
});
|
||||
|
||||
it("view() carries flags, never arg arrays", () => {
|
||||
const v = aiTools.view();
|
||||
assert.equal(v.default, "claude");
|
||||
const pi = v.tools.find((t) => t.id === "pi");
|
||||
assert.deepEqual(pi, {
|
||||
id: "pi",
|
||||
label: "Pi Custom",
|
||||
command: "pi",
|
||||
homepageUrl: null,
|
||||
installed: true,
|
||||
path: path.join(bin, "pi"),
|
||||
enabled: true,
|
||||
source: "custom",
|
||||
launcher: null,
|
||||
prompt: { mode: "flag" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
});
|
||||
assert.equal(v.tools.find((t) => t.id === "codex").routerCapable, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in catalog", () => {
|
||||
it("loads every shipped file", () => {
|
||||
aiTools.__setCatalogDirs(null);
|
||||
const ids = aiTools.list().map((t) => t.id);
|
||||
const files = fs
|
||||
.readdirSync(path.join(__dirname, "..", "data", "ai-tools"))
|
||||
.filter((f) => f.endsWith(".json"));
|
||||
assert.equal(ids.length, files.length);
|
||||
assert.deepEqual(ids.slice(0, 2), ["claude", "codex"]);
|
||||
assert.equal(aiTools.get("antigravity").prompt.flag, "-i");
|
||||
aiTools.__setCatalogDirs([builtin, custom]);
|
||||
});
|
||||
});
|
||||
@@ -1441,7 +1441,7 @@ describe("/api/lanes tool tests", () => {
|
||||
it("POST /api/lanes/ensure rejects an unknown tool", async () => {
|
||||
const res = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/bad-tool",
|
||||
tool: "cursor",
|
||||
tool: "not-a-tool",
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EBADTOOL");
|
||||
@@ -1595,7 +1595,7 @@ describe("/api/lanes tool tests", () => {
|
||||
|
||||
const badRes = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/dual-invalid-tool-cwd",
|
||||
tool: "cursor", // invalid
|
||||
tool: "not-a-tool", // invalid
|
||||
});
|
||||
assert.equal(badRes.status, 400);
|
||||
assert.equal(badRes.body.error.code, "EBADTOOL");
|
||||
|
||||
@@ -359,7 +359,7 @@ describe("lanes lib", () => {
|
||||
|
||||
it("createLane rejects an unknown tool", () => {
|
||||
assert.throws(
|
||||
() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "cursor" }),
|
||||
() => lanes.createLane({ cwd: "/tmp/tool-bad", tool: "not-a-tool" }),
|
||||
(err) => err.code === "EBADTOOL"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -398,25 +398,162 @@ describe("pty-run", () => {
|
||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||
});
|
||||
|
||||
it("spawnRun runs `agy` for an antigravity lane, dropping flags it has no use for", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 13,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
permissionMode: "bypassPermissions",
|
||||
initialPrompt: "hello",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), ["agy", "-i", "hello"]);
|
||||
describe("catalog tools", () => {
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const BIN = path.join(TMP, "bin");
|
||||
let savedPath;
|
||||
before(() => {
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
savedPath = process.env.PATH;
|
||||
process.env.PATH = `${BIN}:/usr/bin:/bin`;
|
||||
aiTools.refresh();
|
||||
});
|
||||
after(() => {
|
||||
process.env.PATH = savedPath;
|
||||
aiTools.refresh();
|
||||
});
|
||||
const at = (name) => path.join(BIN, name);
|
||||
const envOf = (call, key) => {
|
||||
const i = call.findIndex((a, n) => call[n - 1] === "-e" && a.startsWith(`${key}=`));
|
||||
return i < 0 ? undefined : call[i].slice(key.length + 1);
|
||||
};
|
||||
|
||||
tmux.__reset();
|
||||
const last = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", tool: "antigravity", resumeLast: true })
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), ["agy", "--continue"]);
|
||||
it("starts antigravity by absolute path, dropping flags it has no use for", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 13,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
permissionMode: "bypassPermissions",
|
||||
initialPrompt: "hello",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), [at("agy"), "-i", "hello"]);
|
||||
assert.equal(envOf(call, "PATH"), process.env.PATH);
|
||||
|
||||
tmux.__reset();
|
||||
const last = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 14,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
resumeLast: true,
|
||||
bypass: true,
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
|
||||
});
|
||||
|
||||
it("builds each prompt mode, with fixed args first", () => {
|
||||
const argvFor = (tool, extra = {}) =>
|
||||
paneArgv(
|
||||
captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
||||
)
|
||||
);
|
||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", "-x y"]);
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", "-x y"]);
|
||||
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
||||
at("gemini"),
|
||||
"--prompt-interactive",
|
||||
"-x y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const calls = [];
|
||||
let live = false;
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "has-session" && !live) {
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({
|
||||
laneId: 16,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "goose",
|
||||
initialPrompt: "fix it",
|
||||
bypass: true,
|
||||
});
|
||||
const ns = calls.find((c) => c[0] === "new-session");
|
||||
assert.deepEqual(paneArgv(ns), [at("goose")]);
|
||||
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
live = true;
|
||||
t.mock.timers.tick(3000);
|
||||
assert.deepEqual(
|
||||
calls.find((c) => c[0] === "set-buffer"),
|
||||
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
|
||||
);
|
||||
assert.ok(calls.some((c) => c[0] === "paste-buffer"));
|
||||
|
||||
calls.length = 0;
|
||||
live = false;
|
||||
pty.spawnRun({ laneId: 17, cwd: "/tmp/repo", tool: "goose" });
|
||||
assert.equal(
|
||||
envOf(
|
||||
calls.find((c) => c[0] === "new-session"),
|
||||
"GOOSE_MODE"
|
||||
),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the paste when the pane is gone", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "goose", initialPrompt: "hi" });
|
||||
t.mock.timers.tick(3000);
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
});
|
||||
|
||||
it("codex bypass replaces sandbox/approval; claude ignores bypass", () => {
|
||||
const codex = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 19,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "codex",
|
||||
sandbox: "read-only",
|
||||
approval: "never",
|
||||
bypass: true,
|
||||
})
|
||||
);
|
||||
assert.ok(paneArgv(codex).includes("--dangerously-bypass-approvals-and-sandbox"));
|
||||
assert.ok(!paneArgv(codex).includes("-s"));
|
||||
tmux.__reset();
|
||||
const claude = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 20, cwd: "/tmp/repo", bypass: true })
|
||||
);
|
||||
assert.ok(!paneArgv(claude).includes("--dangerously-skip-permissions"));
|
||||
});
|
||||
|
||||
it("refuses a tool the catalog does not know", () => {
|
||||
tmux.__setExecImpl(() => "");
|
||||
assert.throws(() => pty.spawnRun({ laneId: 21, cwd: "/tmp/repo", tool: "nope" }), {
|
||||
code: "ENOTOOL",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file Tests for server/lib/shell-path.js — login-shell PATH hydration with a
|
||||
* stubbed spawner (noisy rc output, errors, opt-out, dedupe) plus one real
|
||||
* `/bin/sh` run to prove the argv works end to end.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hydrateShellPath } = require("../lib/shell-path");
|
||||
|
||||
const M = "__AGENTHUB_PATH__";
|
||||
const stub =
|
||||
(stdout, err = null) =>
|
||||
(_cmd, _args, _opts, cb) => {
|
||||
setImmediate(() => cb(err, stdout));
|
||||
return { stdin: { end() {} } };
|
||||
};
|
||||
|
||||
describe("hydrateShellPath", () => {
|
||||
let saved;
|
||||
beforeEach(() => {
|
||||
saved = { PATH: process.env.PATH, OPT: process.env.AGENTHUB_SHELL_PATH };
|
||||
process.env.PATH = "/usr/bin:/bin";
|
||||
delete process.env.AGENTHUB_SHELL_PATH;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env.PATH = saved.PATH;
|
||||
if (saved.OPT === undefined) delete process.env.AGENTHUB_SHELL_PATH;
|
||||
else process.env.AGENTHUB_SHELL_PATH = saved.OPT;
|
||||
});
|
||||
|
||||
it("appends new segments from between the delimiters, ignoring rc noise", async () => {
|
||||
const ok = await hydrateShellPath({
|
||||
spawn: stub(`welcome!\n${M}/home/u/.local/bin:/usr/bin:/opt/x${M}bye`),
|
||||
});
|
||||
assert.equal(ok, true);
|
||||
assert.equal(process.env.PATH, "/usr/bin:/bin:/home/u/.local/bin:/opt/x");
|
||||
});
|
||||
|
||||
it("keeps PATH on error or missing delimiters", async () => {
|
||||
assert.equal(await hydrateShellPath({ spawn: stub("", new Error("timeout")) }), false);
|
||||
assert.equal(await hydrateShellPath({ spawn: stub("no markers") }), false);
|
||||
assert.equal(
|
||||
await hydrateShellPath({
|
||||
spawn: () => {
|
||||
throw new Error("ENOENT");
|
||||
},
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(process.env.PATH, "/usr/bin:/bin");
|
||||
});
|
||||
|
||||
it("does nothing with AGENTHUB_SHELL_PATH=0", async () => {
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
assert.equal(await hydrateShellPath({ spawn: stub(`${M}/x${M}`) }), false);
|
||||
assert.equal(process.env.PATH, "/usr/bin:/bin");
|
||||
});
|
||||
|
||||
it("reads a real shell's PATH", async () => {
|
||||
const ok = await hydrateShellPath({ shell: "/bin/sh" });
|
||||
assert.equal(ok, true);
|
||||
assert.ok(process.env.PATH.startsWith("/usr/bin:/bin"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user