/** * @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. * @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)); }); });