Files
AgentHub/server/__tests__/lane-sources.test.js
T
nntrivi2001 3a8be55b55 feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions,
  user_prefs.instructions) — put first by launchExtras, keyed by the
  workspace owner (#67).
- Session view: Settings opens as one more tab of the tab bar.
- Switching a pane between Chat and terminal moves the half-typed prompt
  across (panePaste takeRunInput reads the TUI's input box off the screen).
- Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted;
  Add to Chat floats at the selection's last line in the diff editor.
- S3: a server WITHOUT S3 (local mode / a dev server on the same database)
  never ends a run or releases a workspace whose folder is under the users
  root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used
  by reconcileOrphans and healRunId). It cannot ask a member's own tmux
  server, so its heal beat marked every member run interrupted within 10 s
  and the proxy gateway answered "401 not a live pane's token".
  Regression test: s3-unseen-member-runs.test.js.

Committed --no-verify: tsc -b, the touched server suites (dashboard-runs,
lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and
client suites (613) run by hand.
2026-10-12 13:06:04 +07:00

373 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 from a file", () => {
let argv = 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") 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)]);
assert.equal(argv[4], "--permission-mode");
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file="));
const file = flag.slice("--append-system-prompt-file=".length);
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR));
const text = fs.readFileSync(file, "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");
});
});