/** * @file Tests for the File Explorer on a user's HOME (`/api/homes`, * server/lib/homes.js + file-explorer.js `inHome`): the caller's own home is * listed and writable, every dot entry at its top stays hidden and refused * (also through a symlink, a download or a zip) while a nested dotfile is * fine, delete/restore work with no workspace, a copy goes home ↔ workspace * under each side's own rule — and in server mode a developer never reaches * another user's home while an admin gets it read-only — except its Trash, * which the admin lists, restores from and purges as the member's own. * @author Nguyễn Ngọc Trí Vĩ */ 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(), "home-files-")); const HOME = path.join(ROOT, "home"); fs.mkdirSync(HOME); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db"); process.env.CLAUDE_HOME = path.join(HOME, ".claude"); process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.HOME = HOME; const { createApp } = require("../index"); const AdmZip = require("adm-zip"); let app; let base; let lane; const WS = path.join(HOME, "projects", "app"); function request(method, p, body) { return fetch(`${base}${p}`, { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }).then(async (r) => { const buf = Buffer.from(await r.arrayBuffer()); let json = null; try { json = JSON.parse(buf.toString("utf8")); } catch { /* binary */ } return { status: r.status, body: json, buf }; }); } const H = "/api/homes/me/fs"; const ls = async (p = "") => (await request("GET", `${H}/list?path=${encodeURIComponent(p)}`)).body; before(async () => { fs.mkdirSync(path.join(HOME, ".ssh")); fs.writeFileSync(path.join(HOME, ".ssh", "id_ed25519"), "PRIVATE"); fs.writeFileSync(path.join(HOME, ".bashrc"), "alias x=y\n"); fs.symlinkSync(".ssh", path.join(HOME, "keys")); fs.mkdirSync(path.join(HOME, "Documents")); fs.writeFileSync(path.join(HOME, "Documents", "notes.txt"), "hello\n"); fs.mkdirSync(WS, { recursive: true }); fs.writeFileSync(path.join(WS, ".env.example"), "A=1\n"); 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}`; lane = (await request("POST", "/api/lanes/ensure", { cwd: WS, exact: true })).body.lane.id; }); after(async () => { await new Promise((r) => app.close(r)); }); describe("File Explorer on the home", () => { it("offers the caller's own home", async () => { const r = await request("GET", "/api/homes"); assert.deepEqual( r.body.homes.map((h) => [h.id, h.readOnly]), [["me", false]] ); }); it("lists the home with every top-level dot entry hidden", async () => { const l = await ls(); const names = l.entries.map((e) => e.name); assert.ok(names.includes("Documents") && names.includes("projects")); assert.ok(!names.some((n) => n.startsWith(".")), names.join(",")); for (const p of [".ssh", ".bashrc", "keys", ".claude"]) { const r = await request("GET", `${H}/list?path=${encodeURIComponent(p)}`); assert.ok([403, 404].includes(r.status), `${p} → ${r.status}`); } }); it("never serves a top-level dotfile — not by download, a symlink or a zip", async () => { for (const p of [".bashrc", ".ssh/id_ed25519", "keys/id_ed25519"]) { const r = await request("GET", `${H}/download?path=${encodeURIComponent(p)}`); assert.ok(r.status >= 400, `${p} → ${r.status}`); assert.ok(!r.buf.toString().includes("PRIVATE")); } const z = await request("GET", `${H}/zip?p=Documents`); assert.equal(z.status, 200); assert.deepEqual( new AdmZip(z.buf) .getEntries() .map((e) => e.entryName) .sort(), ["Documents/", "Documents/notes.txt"] ); const zDot = await request("GET", `${H}/zip?p=.ssh`); assert.ok(zDot.status >= 400); }); it("downloads an ordinary file of the home", async () => { const r = await request("GET", `${H}/download?path=Documents/notes.txt`); assert.equal(r.status, 200); assert.equal(r.buf.toString(), "hello\n"); }); it("makes and trashes things in the home, but never a top-level dot entry", async () => { assert.equal((await request("POST", `${H}/mkdir`, { path: "Downloads" })).status, 201); assert.equal( (await request("POST", `${H}/newfile`, { path: "Downloads/.hidden-ok" })).status, 201 ); assert.equal((await request("POST", `${H}/newfile`, { path: ".profile2" })).status, 403); assert.equal((await request("POST", `${H}/mkdir`, { path: ".ssh/x" })).status, 403); assert.equal( (await request("POST", `${H}/rename`, { path: "Downloads", name: ".Downloads" })).status, 403 ); const del = await request("POST", `${H}/delete`, { paths: ["Downloads"] }); assert.equal(del.status, 200); const trash = (await request("GET", `${H}/trash`)).body.items; assert.equal(trash[0].name, "Downloads"); assert.equal(trash[0].laneId, null); assert.equal((await request("POST", `${H}/trash/restore`, { id: trash[0].id })).status, 200); assert.ok(fs.existsSync(path.join(HOME, "Downloads", ".hidden-ok"))); }); it("copies home → workspace and workspace → home, each under its own rule", async () => { const toWs = await request("POST", `/api/lanes/${lane}/fs/copy`, { paths: ["Documents/notes.txt"], dest: "", fromHome: "me", }); assert.equal(toWs.status, 200, JSON.stringify(toWs.body)); assert.ok(fs.existsSync(path.join(WS, "notes.txt"))); // A workspace's own dot file is a fine source… const toHome = await request("POST", `${H}/copy`, { paths: [".env.example"], dest: "Documents", fromLane: lane, }); assert.equal(toHome.status, 200, JSON.stringify(toHome.body)); assert.ok(fs.existsSync(path.join(HOME, "Documents", ".env.example"))); // …a home's top-level one is not. const leak = await request("POST", `/api/lanes/${lane}/fs/copy`, { paths: ["keys/id_ed25519"], dest: "", fromHome: "me", }); assert.equal(leak.status, 403); assert.ok(!fs.existsSync(path.join(WS, "id_ed25519"))); }); it("refuses an unknown home", async () => { assert.equal((await request("GET", "/api/homes/someone/fs/list")).status, 404); }); }); describe("homes in server mode", () => { const homes = require("../lib/homes"); const USERS = path.join(ROOT, "users"); before(() => { fs.mkdirSync(path.join(USERS, "bob"), { recursive: true }); fs.mkdirSync(path.join(USERS, "ann"), { recursive: true }); process.env.AGENTHUB_MODE = "server"; process.env.AGENTHUB_USERS_ROOT = USERS; const { db } = require("../db"); db.prepare( "INSERT INTO users (username, password_hash, role) VALUES ('bob', 'x', 'developer'), ('ann', 'x', 'admin')" ).run(); }); after(() => { delete process.env.AGENTHUB_MODE; delete process.env.AGENTHUB_USERS_ROOT; }); it("gives a developer only their own folder as home", () => { const bob = { id: 1, username: "bob", role: "developer" }; assert.equal(homes.resolveHome({ user: bob }, "me").dir, path.join(USERS, "bob")); assert.throws(() => homes.resolveHome({ user: bob }, "ann"), { code: "ENOHOME" }); assert.deepEqual( homes.listHomes({ user: bob }).map((h) => h.id), ["me"] ); }); it("lets an admin open another user's home read-only", () => { const ann = { id: 2, username: "ann", role: "admin" }; const h = homes.resolveHome({ user: ann }, "bob"); assert.equal(h.readOnly, true); assert.equal(h.dir, path.join(USERS, "bob")); assert.ok(homes.listHomes({ user: ann }).some((x) => x.id === "bob" && x.readOnly)); }); }); describe("an admin manages a member's Trash", () => { const auth = require("../lib/auth"); const USERS = path.join(ROOT, "users"); let cara; let dan; const call = (method, p, cookie, body) => fetch(`${base}${p}`, { method, headers: { "Content-Type": "application/json", cookie }, body: body === undefined ? undefined : JSON.stringify(body), }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); const login = async (username, password) => { const r = await fetch(`${base}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); return r.headers.get("set-cookie").split(";")[0]; }; before(async () => { fs.mkdirSync(path.join(USERS, "cara"), { recursive: true }); process.env.AGENTHUB_MODE = "server"; process.env.AGENTHUB_USERS_ROOT = USERS; await auth.createUser("cara", "cara-pass-1", "developer"); await auth.createUser("dan", "dan-pass-1", "admin"); cara = await login("cara", "cara-pass-1"); dan = await login("dan", "dan-pass-1"); }); after(() => { delete process.env.AGENTHUB_MODE; delete process.env.AGENTHUB_USERS_ROOT; }); it("lists, restores and purges the member's own trash — the rest stays read-only", async () => { const C = "/api/homes/me/fs"; const D = "/api/homes/cara/fs"; for (const n of ["old", "junk"]) { assert.equal((await call("POST", `${C}/mkdir`, cara, { path: n })).status, 201); } assert.equal((await call("POST", `${C}/delete`, cara, { paths: ["old", "junk"] })).status, 200); // The admin sees CARA's trash, not their own. let items = (await call("GET", `${D}/trash`, dan)).body.items; assert.deepEqual(items.map((i) => i.name).sort(), ["junk", "old"]); assert.equal((await call("GET", "/api/homes/me/fs/trash", dan)).body?.items?.length ?? 0, 0); // …and every user's in one list, each item tagged with its home and owner. const all = (await call("GET", "/api/homes/trash", dan)).body; assert.deepEqual( all.items.map((i) => [i.name, i.home, i.owner]).sort(), [ ["junk", "cara", "cara"], ["old", "cara", "cara"], ] ); // A developer's merged view is only their own. assert.deepEqual( (await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home), ["me", "me"] ); const old = items.find((i) => i.name === "old"); const r = await call("POST", `${D}/trash/restore`, dan, { id: old.id }); assert.equal(r.status, 200, JSON.stringify(r.body)); assert.ok(fs.statSync(path.join(USERS, "cara", "old")).isDirectory()); assert.equal((await call("POST", `${D}/trash/purge`, dan, { id: "all" })).body.purged, 1); assert.equal((await call("GET", `${C}/trash`, cara)).body.items.length, 0); // Everything else in another user's home is still refused. const w = await call("POST", `${D}/mkdir`, dan, { path: "nope" }); assert.equal(w.status, 403); assert.equal(w.body.error.code, "EREADONLY"); assert.equal((await call("POST", `${D}/delete`, dan, { paths: ["old"] })).status, 403); // A developer still never reaches another user's trash. assert.equal((await call("GET", "/api/homes/dan/fs/trash", cara)).status, 404); }); }); describe("one recycle bin per user", () => { const fx = require("../lib/file-explorer"); const runAs = require("../lib/run-as"); const MEMBER = path.join(ROOT, "member-home"); const put = (root, id, name, laneId) => { fs.mkdirSync(path.join(root, id, "item"), { recursive: true }); fs.writeFileSync( path.join(root, id, "meta.json"), JSON.stringify({ id, name, laneId, rel: name, isDir: true, deletedAt: new Date().toISOString(), }) ); }; it("has one store when the home is the host's", async () => { const out = await fx.listUserTrash(HOME, "solo"); assert.deepEqual(out.items, []); put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "solo"), "solo0001", "x", null); const items = (await fx.listUserTrash(HOME, "solo")).items; assert.deepEqual( items.map((i) => [i.name, i.store]), [["x", "home"]] ); assert.equal( (await fx.purgeUserTrash(HOME, "solo", "host", "solo0001").catch((e) => e)).code, "EBADPATH" ); }); it("merges a member's home trash with their host trash, each item tagged", async (t) => { fs.mkdirSync(MEMBER, { recursive: true }); const acct = { name: "mem", home: MEMBER, uid: process.getuid() }; const orig = runAs.accountForPath; t.after(() => { runAs.accountForPath = orig; }); runAs.accountForPath = (p) => (String(p).startsWith(MEMBER) ? acct : null); put(path.join(MEMBER, ".local", "share", "agenthub", "trash"), "memhome01", "fromHome", null); put(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77"), "memhost01", "fromHostWs", 5); const items = (await fx.listUserTrash(MEMBER, "77")).items; assert.deepEqual(items.map((i) => [i.name, i.store]).sort(), [ ["fromHome", "home"], ["fromHostWs", "host"], ]); // A host item whose workspace is gone has nowhere to go back to. const r = await fx .restoreUserTrash(MEMBER, "77", "host", "memhost01", () => null) .catch((e) => e); assert.equal(r.code, "ENOFILE"); assert.equal((await fx.purgeUserTrash(MEMBER, "77", "host", "memhost01")).purged, 1); assert.ok( !fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "trash", "77", "memhost01")) ); }); });