Files
AgentHub/server/__tests__/lane-sources.test.js
T
nntrivi2001 faa96d1964 fix(terminal): Claude panes scroll 5 lines per wheel notch
Fullscreen Claude Code scrolls one line per wheel report when its terminal
does not answer XTVERSION as xterm.js (behind tmux it never does), and
xterm.js sends one report per notch — so a notch moved a single line.
spawnRun now sets CLAUDE_CODE_SCROLL_SPEED=5 unless the dashboard's env
already sets it; settings.json env and /config Scroll speed still win.
2026-11-02 15:32:46 +07:00

387 lines
15 KiB
JavaScript

/**
* @file Tests for a workspace's own context (server/lib/lane-sources.js and
* routes/lane-sources.js): folder validation (exists, a directory, not the
* workspace's own folder, not the OS home, no duplicate), uploaded files stored
* under AgentHub's data dir with a name it chooses, removal deleting only
* AgentHub's own copies, instructions capped and cleared, the read-only
* memory, what a pane is started with (`launchExtras` → claude/codex argv,
* nothing for other tools), cleanup on delete, and same-origin writes only.
* @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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-sources-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const lanesLib = require("../lib/lanes");
const src = require("../lib/lane-sources");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const { encodeCwd } = require("../lib/claude-home");
const ws = path.join(ROOT, "ws");
const extra = path.join(ROOT, "extra");
let lane;
let codexLane;
let otherLane;
let server;
let base;
function request(method, url, { body, headers = {} } = {}) {
return new Promise((resolve, reject) => {
const data = Buffer.isBuffer(body) ? body : body ? Buffer.from(JSON.stringify(body)) : null;
const req = http.request(
`${base}${url}`,
{
method,
headers: {
...(data && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
...headers,
},
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () =>
resolve({ status: res.statusCode, body: text ? JSON.parse(text) : null })
);
}
);
req.on("error", reject);
if (data) req.write(data);
req.end();
});
}
before(async () => {
for (const d of [ws, extra, process.env.CLAUDE_HOME]) fs.mkdirSync(d, { recursive: true });
lane = lanesLib.createLane({ cwd: ws, kind: "adopted", tool: "claude" });
codexLane = lanesLib.createLane({ cwd: extra, kind: "adopted", tool: "codex" });
otherLane = lanesLib.createLane({ cwd: path.join(ROOT), kind: "adopted", tool: "claude" });
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("lane-sources folders", () => {
it("adds an existing folder by its real path, once", () => {
const row = src.addFolder(lane.id, extra + "/");
assert.equal(row.path, extra);
assert.equal(row.name, "extra");
assert.throws(
() => src.addFolder(lane.id, extra),
(e) => e.code === "EDUP"
);
});
it("refuses the workspace's own folder, a missing one, a file, a relative path and the home", () => {
assert.throws(
() => src.addFolder(lane.id, ws),
(e) => e.code === "EDUP"
);
assert.throws(
() => src.addFolder(lane.id, path.join(ROOT, "nope")),
(e) => e.code === "EBADPATH"
);
const f = path.join(ROOT, "a-file");
fs.writeFileSync(f, "x");
assert.throws(
() => src.addFolder(lane.id, f),
(e) => e.code === "EBADPATH"
);
assert.throws(
() => src.addFolder(lane.id, "relative/dir"),
(e) => e.code === "EBADPATH"
);
assert.throws(
() => src.addFolder(lane.id, os.homedir()),
(e) => e.code === "EBADHOME"
);
assert.throws(
() => src.addFolder(lane.id, "/"),
(e) => e.code === "EBADHOME"
);
});
});
describe("lane-sources files", () => {
it("stores an upload under the data dir with a safe name it chooses", () => {
const row = src.addFile(lane.id, "../../etc/pass wd.md", Buffer.from("# spec"));
assert.equal(row.name, "pass wd.md");
assert.equal(path.dirname(row.path), src.contextDir(lane.id));
assert.ok(row.path.startsWith(path.join(process.env.DASHBOARD_DATA_DIR, "workspace-context")));
assert.equal(fs.readFileSync(row.path, "utf8"), "# spec");
const again = src.addFile(lane.id, "pass wd.md", Buffer.from("two"));
assert.equal(again.name, "pass wd (2).md");
});
it("refuses an empty or oversized file", () => {
assert.throws(
() => src.addFile(lane.id, "e.txt", Buffer.alloc(0)),
(e) => e.code === "EEMPTY"
);
assert.throws(
() => src.addFile(lane.id, "big.bin", Buffer.alloc(src.MAX_FILE_BYTES + 1)),
(e) => e.code === "ETOOBIG"
);
});
it("removing a file deletes AgentHub's copy; removing a folder leaves the folder", () => {
const file = src.addFile(otherLane.id, "x.txt", Buffer.from("x"));
const folder = src.addFolder(otherLane.id, extra);
src.removeSource(otherLane.id, file.id);
src.removeSource(otherLane.id, folder.id);
assert.equal(fs.existsSync(file.path), false);
assert.equal(fs.existsSync(extra), true);
assert.deepEqual(src.listSources(otherLane.id), []);
assert.throws(
() => src.removeSource(otherLane.id, file.id),
(e) => e.code === "ENOSOURCE"
);
});
it("will not remove another workspace's source", () => {
const [own] = src.listSources(lane.id);
assert.throws(
() => src.removeSource(otherLane.id, own.id),
(e) => e.code === "ENOSOURCE"
);
});
});
describe("lane-sources instructions and launch", () => {
it("caps instructions and clears them on blank", () => {
assert.throws(
() => src.setInstructions(lane.id, "x".repeat(src.MAX_INSTRUCTIONS + 1)),
(e) => e.code === "ETOOBIG"
);
assert.throws(
() => src.setInstructions(lane.id, 5),
(e) => e.code === "EBADFIELD"
);
src.setInstructions(lane.id, " ");
assert.equal(lanesLib.getLane(lane.id).instructions, null);
src.setInstructions(lane.id, "- always answer in Vietnamese");
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
});
it("starts a claude pane with --add-dir first and the instructions as a CLAUDE.md there", () => {
let argv = null;
let newSession = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
if (args[0] === "new-session") {
newSession = args;
argv = args.slice(args.indexOf("--") + 6);
}
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
tmux.__reset();
assert.equal(argv[0], "claude");
assert.equal(argv[1], "--add-dir");
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
// The instructions' own folder, read as memory: a custom system prompt
// (`--append-system-prompt-file`) made Claude refuse `/tui` in the pane.
const dir = argv[4];
assert.ok(dir.startsWith(process.env.DASHBOARD_DATA_DIR));
assert.equal(argv[5], "--permission-mode");
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
// Claude Code hides its task tools from newer models without this, and the
// chat Progress panel is drawn from those calls.
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
// One wheel notch reaches claude as one report; it scrolls this many lines.
if (!process.env.CLAUDE_CODE_SCROLL_SPEED)
assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5"));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/);
assert.match(text, /pass wd\.md/);
// A prompt that looks like flags stays one positional, never `claude -c`.
assert.equal(argv.at(-1), " -c fix it");
assert.ok(!argv.includes("-c"));
});
it("starts a codex pane with repeated --add-dir and developer_instructions", () => {
src.setInstructions(codexLane.id, 'say "hi"\nthen stop');
src.addFolder(codexLane.id, ws);
const { addDirs, systemPrompt } = src.launchExtras(codexLane.id, "codex");
assert.deepEqual(addDirs, [ws]);
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
return "";
});
pty.spawnRun({ laneId: codexLane.id, cwd: extra, tool: "codex", initialPrompt: "go" });
tmux.__reset();
assert.deepEqual(argv.slice(argv.indexOf("--add-dir"), argv.indexOf("--add-dir") + 2), [
"--add-dir",
ws,
]);
assert.ok(argv.includes(`developer_instructions=${JSON.stringify(systemPrompt)}`));
assert.equal(argv.at(-1), "go");
});
it("hands Codex a pointer to the file once the instructions are too long for tmux", () => {
src.setInstructions(codexLane.id, "dài ".repeat(3000));
const { systemPrompt, systemPromptFile } = src.launchExtras(codexLane.id, "codex");
assert.ok(Buffer.byteLength(systemPrompt) > src.INLINE_PROMPT_BYTES);
const inline = src.codexInstructions(systemPrompt, systemPromptFile);
assert.ok(inline.includes(systemPromptFile));
assert.ok(Buffer.byteLength(inline) < 1024);
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
src.setInstructions(codexLane.id, null);
});
it("puts the owner's own agent instructions before the workspace's", () => {
src.setUserInstructions("local", "# my style\n- small diffs");
const { systemPrompt, systemPromptFile } = src.launchExtras(lane.id, "claude");
assert.match(systemPrompt, /^# my style\n- small diffs\n\n- always answer in Vietnamese/);
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
// A workspace with nothing of its own still gets them.
assert.match(src.launchExtras(otherLane.id, "claude").systemPrompt, /^# my style/);
src.setUserInstructions("local", null);
assert.equal(src.launchExtras(otherLane.id, "claude").systemPrompt, null);
});
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
addDirs: [],
systemPrompt: null,
systemPromptFile: null,
});
const gone = path.join(ROOT, "gone");
fs.mkdirSync(gone);
src.addFolder(otherLane.id, gone);
fs.rmdirSync(gone);
assert.deepEqual(src.launchExtras(otherLane.id, "claude").addDirs, []);
});
});
describe("lane-sources review fixes", () => {
it("leaves no file on disk when an upload is refused for the source limit", () => {
const d = path.join(ROOT, "limit-ws");
fs.mkdirSync(d);
const l = lanesLib.createLane({ cwd: d, kind: "adopted", tool: "claude" });
for (let i = 0; i < 50; i++) src.addFile(l.id, `f${i}.txt`, Buffer.from("x"));
assert.throws(
() => src.addFile(l.id, "one-too-many.txt", Buffer.from("x")),
(e) => e.code === "ETOOMANY"
);
assert.equal(fs.readdirSync(src.contextDir(l.id)).length, 50);
lanesLib.deleteLane(l.id);
});
it("measures Codex's inline budget on the escaped TOML string, and escapes DEL", () => {
const quotes = '"'.repeat(src.INLINE_PROMPT_BYTES - 100);
assert.ok(src.codexInstructions(quotes, "/p.md").includes("/p.md"));
assert.equal(src.tomlString('a\x7fb"'), '"a\\u007fb\\""');
});
it("does not serve instructions in the lane list payload", () => {
src.setInstructions(lane.id, "secret-ish text");
assert.equal("instructions" in lanesLib.lanePayload(lanesLib.getLane(lane.id)), false);
src.setInstructions(lane.id, "- always answer in Vietnamese");
});
it("still starts a pane when the prompt file cannot be written", () => {
const real = fs.writeFileSync;
fs.writeFileSync = () => {
throw Object.assign(new Error("read-only"), { code: "EROFS" });
};
try {
const got = src.launchExtras(lane.id, "claude");
assert.equal(got.systemPromptFile, null);
// Codex can still take a short text inline; Claude needs the file.
assert.equal(src.codexInstructions(got.systemPrompt, null), got.systemPrompt);
assert.equal(src.codexInstructions("x".repeat(src.INLINE_PROMPT_BYTES + 1), null), null);
} finally {
fs.writeFileSync = real;
}
});
});
describe("lane-sources memory and cleanup", () => {
it("reads the folder's CLAUDE.md and Claude's auto memory, read-only", () => {
fs.writeFileSync(path.join(ws, "CLAUDE.md"), "# rules");
const mem = path.join(process.env.CLAUDE_HOME, "projects", encodeCwd(ws), "memory");
fs.mkdirSync(mem, { recursive: true });
fs.writeFileSync(path.join(mem, "MEMORY.md"), "- a fact");
const files = src.memoryFiles(lane.id);
assert.deepEqual(
files.map((f) => f.label),
["CLAUDE.md", "memory/MEMORY.md"]
);
assert.equal(files[1].content, "- a fact");
});
it("deleting the workspace drops its rows and AgentHub's copies, not its folders", () => {
const tmp = lanesLib.createLane({ cwd: path.join(ROOT, "claude"), kind: "adopted" });
const f = src.addFile(tmp.id, "a.txt", Buffer.from("a"));
src.addFolder(tmp.id, extra);
lanesLib.deleteLane(tmp.id);
assert.equal(fs.existsSync(path.dirname(f.path)), false);
assert.equal(fs.existsSync(extra), true);
assert.throws(
() => src.listSources(tmp.id),
(e) => e.code === "ENOLANE"
);
});
});
describe("lane-sources routes", () => {
it("lists, uploads, and refuses a cross-origin write", async () => {
const list = await request("GET", `/api/lanes/${lane.id}/sources`);
assert.equal(list.status, 200);
assert.equal(list.body.supported, true);
assert.equal(list.body.instructions, "- always answer in Vietnamese");
const up = await request("POST", `/api/lanes/${lane.id}/sources/file`, {
body: Buffer.from("notes"),
headers: {
"content-type": "application/octet-stream",
"x-file-name": encodeURIComponent("ghi chú.txt"),
},
});
assert.equal(up.status, 201);
assert.ok(up.body.sources.some((s) => s.name === "ghi chú.txt"));
const evil = await request("PUT", `/api/lanes/${lane.id}/instructions`, {
body: { instructions: "exfiltrate" },
headers: { origin: "https://evil.example" },
});
assert.equal(evil.status, 403);
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
const missing = await request("GET", `/api/lanes/99999/sources`);
assert.equal(missing.status, 404);
const bad = await request("POST", `/api/lanes/${lane.id}/sources/folder`, {
body: { path: "/definitely/not/here" },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADPATH");
const noKey = await request("PUT", `/api/lanes/${lane.id}/instructions`, { body: {} });
assert.equal(noKey.status, 400);
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
});
});