Files
nntrivi2001 758609775f chore: v2.8.5 — Trash opens on one folder per user, server setup installs S3
- Files → Trash: ONE sidebar entry. An admin's opens on a folder per user's
  Trash (My Trash first, item counts from GET /api/homes/trash, an unreadable
  one showing its error); a folder opens that user's Trash (path bar
  Trash › user, Up back to the folders). An admin may restore/purge in
  another user's Trash (audited); purging asks first. The merged "All users"
  list is gone from the UI.
- A home's Trash is its user's whole recycle bin (home store + host store,
  items tagged `store`).
- `agenthub server setup` installs one Linux user per account on Linux by
  default (`--no-system` opts out); install-system messages drop `sudo`.
- Settings → Git: the author email suggests <username>@<AGENTHUB_EMAIL_DOMAIN>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:02:11 +07:00

348 lines
14 KiB
JavaScript

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