e94038481e
- 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>
207 lines
7.9 KiB
JavaScript
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));
|
|
});
|
|
});
|