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>
This commit is contained in:
@@ -246,6 +246,8 @@ describe("/api/me/git", () => {
|
||||
const made = await request("POST", "/api/me/git/ssh-key", {});
|
||||
assert.equal(made.status, 201);
|
||||
assert.match(made.body.ssh.publicKey, /^ssh-ed25519 /);
|
||||
// No commit author yet: the key is labelled with the default email.
|
||||
assert.match(made.body.ssh.publicKey, /@smartgift\.vn$/);
|
||||
assert.match(made.body.ssh.fingerprint, /^SHA256:/);
|
||||
assert.ok(!JSON.stringify(made.body).includes("PRIVATE"));
|
||||
const key = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519");
|
||||
@@ -311,6 +313,7 @@ describe("/api/me/git", () => {
|
||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||
const r0 = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||
assert.equal(r0.body.defaultEmail, `${os.userInfo().username}@smartgift.vn`);
|
||||
const bad = await request("PUT", "/api/me/git/author", { name: "a\nb", email: "x@y" });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADAUTHOR");
|
||||
@@ -318,6 +321,10 @@ describe("/api/me/git", () => {
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.author, { name: "Vi", email: "vi@example.com" });
|
||||
assert.match(fs.readFileSync(path.join(ROOT, ".gitconfig"), "utf8"), /email = vi@example.com/);
|
||||
// A key made now is labelled with the author's email.
|
||||
const key = await request("POST", "/api/me/git/ssh-key", { replace: true });
|
||||
assert.match(key.body.ssh.publicKey, / vi@example\.com$/);
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
|
||||
it("saves an https login without ever returning the token", async () => {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* (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.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -204,3 +205,143 @@ describe("homes in server mode", () => {
|
||||
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"))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user