Files
AgentHub/server/__tests__/home-files.test.js
T
nntrivi2001 e94038481e feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:17:39 +07:00

207 lines
7.9 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.
* @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));
});
});