3a8be55b55
- 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.
373 lines
15 KiB
JavaScript
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");
|
|
});
|
|
});
|