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:
2026-10-15 16:02:11 +07:00
parent 09efb089cf
commit 758609775f
27 changed files with 839 additions and 107 deletions
+21 -12
View File
@@ -6,13 +6,15 @@
* git uses that key (`core.sshCommand`), the SSH login name per host
* (`url.<…>.insteadOf` — AWS CodeCommit's SSH key ID), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail
* without one). The account is the signed-in user's
* without one; a new SSH key is labelled with that email, else
* `<username>@smartgift.vn`). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const express = require("express");
const identity = require("../lib/git-identity");
@@ -39,7 +41,7 @@ function fail(res, e) {
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
async function view(account) {
async function view(account, username) {
const keys = identity.sshKeysInfo(account);
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
return {
@@ -52,9 +54,14 @@ async function view(account) {
sshUsers: await identity.listSshUsers(account),
https: identity.listHttps(account),
author: await identity.getAuthor(account),
// What the author email form suggests while none is set.
defaultEmail: identity.defaultEmail(username),
};
}
/** The signed-in user's name, or the OS user's in local mode. */
const usernameOf = (req) => req.user?.username || os.userInfo().username;
/** The request's account; a write to the host's identity needs an admin. */
function accountOf(req, { write = false } = {}) {
const account = identity.accountForRequest(req);
@@ -68,7 +75,7 @@ function accountOf(req, { write = false } = {}) {
router.get("/", async (req, res) => {
try {
res.json(await view(accountOf(req)));
res.json(await view(accountOf(req), usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -80,9 +87,11 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
await identity.generateSshKey(account, {
replace: req.body?.replace === true,
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
comment: req.user?.username ? `${req.user.username}@agenthub` : "agenthub",
// The key is labelled with the commit author's email, else the default one.
comment:
(await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
});
res.status(201).json(await view(account));
res.status(201).json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -95,7 +104,7 @@ router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
if (!(await identity.removeSshKey(account, type))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -112,7 +121,7 @@ router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
await identity.setTerminalSsh(account, req.body?.on === true);
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -126,7 +135,7 @@ router.put("/ssh-user", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setSshUser(account, req.body || {});
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -138,7 +147,7 @@ router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
if (!(await identity.removeSshUser(account, req.params.host))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
}
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -149,7 +158,7 @@ router.post("/https", sameOriginGuard, async (req, res) => {
const account = accountOf(req, { write: true });
const { host, username, password } = req.body || {};
identity.saveHttps(account, { protocol: "https", host, username, password });
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -161,7 +170,7 @@ router.delete("/https/:host", sameOriginGuard, async (req, res) => {
if (!identity.removeHttps(account, "https", req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
}
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
@@ -172,7 +181,7 @@ router.put("/author", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setAuthor(account, req.body || {});
res.json(await view(account));
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}