feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP

- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 17:10:29 +07:00
parent 9c573f1e3c
commit 0c2f49876d
74 changed files with 5347 additions and 392 deletions
@@ -0,0 +1,57 @@
/**
* @file ai-proxies-9router.test.js
* @description `GET /api/ai-proxies/9router` — reports whether a `9router`
* binary is on the server's PATH (the AI Proxy page shows its Config 9router
* link only then), and never runs it.
* @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 path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ai-proxies-9router-"));
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
const express = require("express");
const router = require("../routes/ai-proxies");
let server;
let BASE;
const ORIGINAL_PATH = process.env.PATH;
const BIN = path.join(TMP, "bin");
before(async () => {
fs.mkdirSync(BIN, { recursive: true });
const app = express();
app.use("/api/ai-proxies", router);
await new Promise((resolve) => {
server = app.listen(0, "127.0.0.1", resolve);
});
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
process.env.PATH = ORIGINAL_PATH;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
const installed = async () =>
(await (await fetch(`${BASE}/api/ai-proxies/9router`)).json()).installed;
describe("GET /api/ai-proxies/9router", () => {
it("is false when no 9router binary is on PATH", async () => {
process.env.PATH = path.join(TMP, "empty");
assert.equal(await installed(), false);
});
it("is true once an executable 9router is on PATH", async () => {
const bin = path.join(BIN, "9router");
fs.writeFileSync(bin, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
process.env.PATH = BIN;
assert.equal(await installed(), true);
});
});
+42
View File
@@ -3121,6 +3121,48 @@ describe("GET /api/sessions/:id/outline", () => {
});
});
describe("GET /api/sessions/:id/file", () => {
it("serves only a text file the session itself read or wrote", async () => {
const sid = "read-file-" + Date.now();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-read-file-"));
const report = path.join(dir, "REPORT.md");
const other = path.join(dir, "other.md");
const bin = path.join(dir, "blob.dat");
const cred = path.join(dir, ".credentials.json");
const link = path.join(dir, "notes.md");
fs.writeFileSync(report, "# Report");
fs.writeFileSync(other, "other");
fs.writeFileSync(bin, Buffer.from([1, 0, 2]));
fs.writeFileSync(cred, "{}");
fs.symlinkSync(cred, link);
const tool = (tool_name, file_path) =>
post("/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: sid, tool_name, tool_input: { file_path }, tool_response: {} },
});
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
await tool("Write", report);
await tool("Read", bin);
await tool("Read", cred);
await tool("Edit", link);
const url = (p) => `/api/sessions/${sid}/file?path=${encodeURIComponent(p)}`;
const ok = await fetch(url(report));
assert.equal(ok.status, 200);
assert.deepEqual(ok.body, { text: "# Report", truncated: false });
// Never touched, binary, a credential (by name or through a link), relative, no session.
assert.equal((await fetch(url(other))).status, 404);
assert.equal((await fetch(url(bin))).status, 415);
assert.equal((await fetch(url(cred))).status, 403);
assert.equal((await fetch(url(link))).status, 403);
assert.equal((await fetch(url("REPORT.md"))).status, 400);
assert.equal((await fetch(`/api/sessions/nope-file/file?path=${report}`)).status, 404);
fs.rmSync(dir, { recursive: true, force: true });
assert.equal((await fetch(url(report))).status, 404);
});
});
describe("GET /api/sessions/:id/image", () => {
it("serves only an image the session itself read", async () => {
const sid = "read-image-" + Date.now();
+284
View File
@@ -0,0 +1,284 @@
/**
* @file claude-pool-login.test.js
* @description The Claude pool's `login` kind (claude-pool.js): a real login
* is written into a MEMBER's `~/.claude/.credentials.json` as the member
* (write-as, played by fixtures/fake-sudo.js) and never offered to a host
* pane or over a member's own login; the server refreshes it ahead of expiry
* and pushes the new pair into every home it placed it in; a pair a member's
* Claude refreshed first is copied back only when Anthropic says it is the
* same account; removing the account takes it out of those homes; and the
* Users-page route moves a saved switch-user profile in — the one this
* machine's own Claude Code is logged into with its LIVE login, the host's
* home then being one more placement the server refreshes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "claude-pool-login-")));
const USERS = path.join(TMP, "users");
const HOME = path.join(USERS, "mia");
const CREDS = path.join(HOME, ".claude", ".credentials.json");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.DASHBOARD_POOL_REFRESH_MS = "0";
process.env.AGENTHUB_CC_KEYCHAIN = "0";
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const pool = require("../lib/claude-pool");
const usage = require("../lib/cc-usage");
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
const H = 3600e3;
const oauth = (tag, expiresIn = 8 * H) => ({
accessToken: `sk-ant-oat01-access-${tag}`,
refreshToken: `sk-ant-ort01-refresh-${tag}`,
expiresAt: Date.now() + expiresIn,
scopes: ["user:inference"],
subscriptionType: "max",
});
const ACCOUNT = { accountUuid: "uuid-team", emailAddress: "team@example.com" };
const diskCreds = () => JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth;
let acct;
let server;
let BASE;
const exchanges = [];
let owners = {};
before(async () => {
fs.mkdirSync(HOME, { recursive: true });
const uid = process.getuid();
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
runAs.__resetCache();
linux.__setRunImpl(() => "ok");
runAs.__setExecImpl(sudo.exec);
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("mia", "member-pass-1", "developer");
acct = runAs.accountForPath(HOME);
assert.ok(acct, "mia is a provisioned member");
// No network: Anthropic's answers are played here.
usage.fetchUsage = async () => ({});
usage.exchangeRefresh = async (creds) => {
exchanges.push(creds.refreshToken);
return oauth(`r${exchanges.length}`);
};
usage.fetchOwner = async (token) => owners[token] || null;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
fs.rmSync(path.join(HOME, ".claude"), { recursive: true, force: true });
fs.rmSync(path.join(HOME, ".claude.json"), { force: true });
fs.rmSync(path.join(TMP, ".claude", "agenthub-accounts"), { recursive: true, force: true });
exchanges.length = 0;
owners = {};
});
describe("a pool login", () => {
it("goes into a member's home as the member, never to a host pane", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT, userID: "u-1" });
assert.equal(pool.forSpawn(null), null, "a host pane keeps the host's own login");
const spawn = pool.forSpawn(acct);
assert.equal(spawn.name, "team");
assert.deepEqual(spawn.env, {}, "no token in the pane's env");
spawn.place();
assert.equal(diskCreds().refreshToken, oauth("a").refreshToken);
assert.equal(fs.statSync(CREDS).mode & 0o777, 0o600);
const state = JSON.parse(fs.readFileSync(path.join(HOME, ".claude.json"), "utf8"));
assert.equal(state.hasCompletedOnboarding, true);
assert.equal(state.oauthAccount.emailAddress, "team@example.com");
assert.ok(sudo.calls.some((c) => c.includes(CREDS) && c.includes("private")));
assert.ok(
!sudo.calls.flat().some((x) => String(x).includes("sk-ant")),
"no secret in sudo argv"
);
});
it("is never written over a member's own login", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
fs.mkdirSync(path.dirname(CREDS), { recursive: true });
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("mine") }));
assert.equal(pool.forSpawn(acct), null);
assert.equal(diskCreds().refreshToken, oauth("mine").refreshToken);
});
it("is refreshed by the server ahead of expiry and pushed into the home", async () => {
pool.addLoginAccount("team", { oauth: oauth("a", 10 * 60e3), account: ACCOUNT });
pool.forSpawn(acct).place();
await pool.refreshLogins();
assert.deepEqual(exchanges, [oauth("a").refreshToken], "the server spent the refresh token");
assert.equal(pool.__read("team").oauth.refreshToken, oauth("r1").refreshToken);
assert.equal(diskCreds().refreshToken, oauth("r1").refreshToken, "the home got the new pair");
await pool.refreshLogins();
assert.equal(exchanges.length, 1, "a fresh login is not refreshed again");
});
it("copies a home's own refresh back only when it is the same account", async () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
pool.forSpawn(acct).place();
// The member's Claude refreshed first.
const theirs = oauth("cli", 9 * H);
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: theirs }));
owners[theirs.accessToken] = { uuid: "uuid-team" };
await pool.refreshLogins();
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken);
// A login of another account is the member's own: dropped, never written over.
const other = oauth("other", 10 * H);
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: other }));
owners[other.accessToken] = { uuid: "uuid-someone-else" };
await pool.refreshLogins();
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken);
assert.deepEqual(pool.__read("team").placed, {});
assert.equal(diskCreds().refreshToken, other.refreshToken);
});
it("is taken out of the homes it is still ours in when removed", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
pool.forSpawn(acct).place();
pool.removeAccount("team");
assert.equal(fs.existsSync(CREDS), false);
assert.equal(pool.__read("team"), null);
});
it("refuses a second copy of one Claude account", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
code: "EEXISTS",
});
assert.throws(() => pool.addLoginAccount("x", { oauth: { accessToken: "a" } }), {
code: "EBADTOKEN",
});
});
});
describe("moving a saved profile in from the Users page", () => {
const profileDir = path.join(TMP, ".claude", "agenthub-profiles");
const writeProfile = (name, tag, uuid) => {
fs.mkdirSync(profileDir, { recursive: true });
fs.writeFileSync(
path.join(profileDir, `${name}.json`),
JSON.stringify({
name,
label: name,
credentials: oauth(tag),
account: { accountUuid: uuid, emailAddress: `${name}@example.com` },
})
);
};
async function call(method, p, body) {
const login = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
});
const cookie = login.headers.get("set-cookie").split(";")[0];
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", cookie },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
it("moves it into the pool — the account this machine runs as with its LIVE login", async () => {
writeProfile("work", "w", "uuid-work");
writeProfile("me", "m-saved", "uuid-me");
// This machine's own Claude Code is logged in as "me", and has refreshed
// since the profile was saved: only the live pair still works.
const hostCreds = path.join(TMP, ".claude", ".credentials.json");
fs.writeFileSync(
hostCreds,
JSON.stringify({ claudeAiOauth: oauth("m-live", 10 * 60e3), mcpOAuth: { keep: 1 } })
);
fs.writeFileSync(
`${path.join(TMP, ".claude")}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-me" } })
);
const list = await call("GET", "/api/accounts/profiles");
assert.deepEqual(
list.body.profiles.map((p) => [p.name, p.active]),
[
["me", true],
["work", false],
]
);
assert.ok(!JSON.stringify(list.body).includes("sk-ant"));
const moved = await call("POST", "/api/accounts", {
tool: "claude",
profile: "work",
currentPassword: "admin-pass-1",
});
assert.equal(moved.status, 201);
assert.equal(moved.body.account.name, "work");
assert.ok(!JSON.stringify(moved.body).includes("sk-ant"));
assert.equal(pool.__read("work").kind, "login");
assert.equal(fs.existsSync(path.join(profileDir, "work.json")), false, "one holder only");
const mine = await call("POST", "/api/accounts", {
tool: "claude",
profile: "me",
currentPassword: "admin-pass-1",
});
assert.equal(mine.status, 201);
const rec = pool.__read("me");
assert.equal(
rec.oauth.refreshToken,
oauth("m-live").refreshToken,
"the live pair, not the saved one"
);
assert.deepEqual(Object.keys(rec.placed), ["@host"]);
assert.equal(fs.existsSync(path.join(profileDir, "me.json")), false);
// The server refreshes it and pushes the new pair into the host's own file,
// keeping its other keys; removing the account leaves the host logged in.
await pool.refreshLogins();
const host = JSON.parse(fs.readFileSync(hostCreds, "utf8"));
assert.equal(host.claudeAiOauth.refreshToken, pool.__read("me").oauth.refreshToken);
assert.notEqual(host.claudeAiOauth.refreshToken, oauth("m-live").refreshToken);
assert.deepEqual(host.mcpOAuth, { keep: 1 });
assert.equal(fs.statSync(hostCreds).mode & 0o777, 0o600);
pool.removeAccount("me");
assert.ok(fs.existsSync(hostCreds), "the host keeps its own login");
pool.removeAccount("work");
});
it("takes a pasted .credentials.json once Anthropic names its account", async () => {
const pasted = oauth("p");
owners[pasted.accessToken] = { uuid: "uuid-pasted", email: "p@example.com" };
const r = await call("POST", "/api/accounts", {
tool: "claude",
name: "pasted",
credentials: JSON.stringify({ claudeAiOauth: pasted }),
currentPassword: "admin-pass-1",
});
assert.equal(r.status, 201);
assert.equal(pool.__read("pasted").account.accountUuid, "uuid-pasted");
});
});
@@ -0,0 +1,146 @@
/**
* @file server-mode-account-pool.test.js
* @description The Claude/Codex account pool from the Users page: only an
* admin may add or remove an account, each change needs the admin's OWN
* password again (EREAUTH), the token only goes in — no answer (the add, the
* list) ever carries it — the audit log names the account, not its secret, and
* in local mode the routes refuse.
* @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 path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-account-pool-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const claudePool = require("../lib/claude-pool");
const codexPool = require("../lib/codex-pool");
// The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({});
let server;
let BASE;
const BOSS_PASS = "admin-pass-1";
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
const KEY = `sk-proj-${"b".repeat(40)}`;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", BOSS_PASS, "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
await auth.createUser("mia", "member-pass-1", "developer");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the account pool from the Users page", () => {
it("lets an admin add a Claude account, never answering with its token", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 201);
assert.equal(r.body.account.name, "team-1");
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
assert.equal(claudePool.pickAccount().token, TOKEN, "new panes run on it");
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.accounts.map((a) => a.name),
["team-1"]
);
assert.ok(!JSON.stringify(list.body).includes(TOKEN));
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.match(audit.path, /add claude account team-1/);
assert.ok(!audit.path.includes(TOKEN));
});
it("adds a Codex API key, and removes accounts again", async () => {
const boss = await login("boss", BOSS_PASS);
const add = await call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "codex", name: "cx-1", token: KEY, currentPassword: BOSS_PASS },
});
assert.equal(add.status, 201);
assert.equal(add.body.account.kind, "apikey");
assert.ok(!JSON.stringify(add.body).includes(KEY));
assert.equal(codexPool.__read("cx-1").key, KEY);
const del = await call("DELETE", "/api/accounts/cx-1", {
cookie: boss,
body: { tool: "codex", currentPassword: BOSS_PASS },
});
assert.equal(del.status, 200);
assert.equal(codexPool.__read("cx-1"), null);
const gone = await call("DELETE", "/api/accounts/cx-1", {
cookie: boss,
body: { tool: "codex", currentPassword: BOSS_PASS },
});
assert.equal(gone.status, 404);
});
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
const boss = await login("boss", BOSS_PASS);
const add = (body) =>
call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "claude", name: "team-2", token: TOKEN, currentPassword: BOSS_PASS, ...body },
});
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
});
it("is an admin's only: an analyst reads it, a developer neither reads nor writes", async () => {
const ana = await login("ana", "analyst-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: ana })).status, 200);
const w = await call("POST", "/api/accounts", {
cookie: ana,
body: { tool: "claude", name: "x", token: TOKEN, currentPassword: "analyst-pass-1" },
});
assert.equal(w.status, 403);
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
const d = await call("DELETE", "/api/accounts/team-1", {
cookie: mia,
body: { tool: "claude", currentPassword: "member-pass-1" },
});
assert.equal(d.status, 403);
assert.ok(claudePool.pickAccount(), "team-1 is still there");
});
});
@@ -0,0 +1,263 @@
/**
* @file server-mode-user-edit.test.js
* @description The Users page's Edit user form (`GET`/`PATCH /api/users/:id`)
* and Settings → Account (`/api/me/account`, `/api/me/password`,
* `/api/me/sign-out-others`): one PATCH validates every field before any is
* written, sets display name/email/role/password/workspaces/sign-out together
* with the admin's own password, never takes a workspace from a third user; a
* user edits their own profile, changes their own password only with the old
* one (keeping the login they changed it from), signs out other devices, and a
* pane token may not do any of it.
* @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 path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-edit-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const ids = {};
const BOSS_PASS = "admin-pass-1";
async function call(method, p, { cookie, body, headers } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
const lane = (cwd, owner = null) =>
Number(
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
.lastInsertRowid
);
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
ids.leo = (await auth.createUser("leo", "member-pass-2", "developer")).id;
ids.free = lane("/w/free");
ids.miaLane = lane("/w/mia", ids.mia);
ids.leoLane = lane("/w/leo", ids.leo);
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("Edit user (PATCH /api/users/:id)", () => {
it("reads one account with its workspaces and logins, never a hash", async () => {
const boss = await login("boss", BOSS_PASS);
await login("mia", "member-pass-1");
const r = await call("GET", `/api/users/${ids.mia}`, { cookie: boss });
assert.equal(r.status, 200);
assert.equal(r.body.user.username, "mia");
assert.equal(r.body.user.logins, 1);
assert.ok(r.body.user.lastLoginAt);
assert.ok(r.body.user.createdAt);
assert.deepEqual(
r.body.workspaces.map((w) => w.id),
[ids.miaLane]
);
assert.ok(!JSON.stringify(r.body).includes("scrypt"));
});
it("applies profile, role, workspaces and password in one request", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: {
currentPassword: BOSS_PASS,
displayName: " Mia Tran ",
email: "mia@example.com",
role: "analyst",
password: "mia-new-pass",
addWorkspaces: [ids.free],
removeWorkspaces: [ids.miaLane],
},
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(r.body.user.displayName, "Mia Tran");
assert.equal(r.body.user.email, "mia@example.com");
assert.equal(r.body.user.role, "analyst");
assert.equal(r.body.user.logins, 0, "a new password ends the user's logins");
const owners = db
.prepare("SELECT id, owner_id FROM lanes WHERE id IN (?, ?)")
.all(ids.free, ids.miaLane);
assert.deepEqual(Object.fromEntries(owners.map((o) => [o.id, o.owner_id])), {
[ids.free]: ids.mia,
[ids.miaLane]: null,
});
assert.ok(await login("mia", "mia-new-pass"));
const audit = db
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
.get(ids.mia).path;
assert.match(audit, /display name, email, role analyst/);
assert.ok(!audit.includes("mia-new-pass"), "the audit never carries the password");
});
it("writes nothing when one field is bad", async () => {
const boss = await login("boss", BOSS_PASS);
const before = db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo);
for (const [body, code] of [
[{ displayName: "Leo", email: "not-an-email" }, "EBADEMAIL"],
[{ displayName: "Leo", password: "" }, "EBADPASSWORD"],
[{ displayName: "Leo", role: "root" }, "EBADROLE"],
[{ displayName: "Leo", addWorkspaces: [99999] }, "ENOLANE"],
[{ displayName: "Leo", addWorkspaces: ["1"] }, "EBADFIELD"],
[{ displayName: "Leo", signOut: "yes" }, "EBADFIELD"],
// Never takes a workspace from a third user.
[{ displayName: "Leo", removeWorkspaces: [ids.free] }, "ENOTOWNED"],
]) {
const r = await call("PATCH", `/api/users/${ids.leo}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
});
assert.equal(r.body?.error?.code, code, JSON.stringify(body));
}
assert.deepEqual(db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo), before);
assert.equal(
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.free).owner_id,
ids.mia
);
});
it("needs the admin's own password, and keeps ESELF", async () => {
const boss = await login("boss", BOSS_PASS);
const noPass = await call("PATCH", `/api/users/${ids.leo}`, {
cookie: boss,
body: { displayName: "x" },
});
assert.equal(noPass.body.error.code, "EREAUTH");
for (const body of [{ role: "developer" }, { disabled: true }]) {
const r = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
});
assert.equal(r.body.error.code, "ESELF");
}
});
it("signs a user out everywhere, but an admin editing themselves keeps this login", async () => {
await login("leo", "member-pass-2");
const boss = await login("boss", BOSS_PASS);
const other = await login("boss", BOSS_PASS);
const r = await call("PATCH", `/api/users/${ids.leo}`, {
cookie: boss,
body: { signOut: true, currentPassword: BOSS_PASS },
});
assert.equal(r.body.user.logins, 0);
const self = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { signOut: true, currentPassword: BOSS_PASS },
});
assert.equal(self.status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: boss })).status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
});
});
describe("display names reach the workspace list and the audit log", () => {
it("carries owner_display_name beside owner_name, and audit display names", async () => {
const boss = await login("boss", BOSS_PASS);
const lanes = (await call("GET", "/api/lanes", { cookie: boss })).body.lanes;
const free = lanes.find((l) => l.id === ids.free);
assert.equal(free.owner_name, "mia");
assert.equal(free.owner_display_name, "Mia Tran");
const audit = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
const row = audit.find((a) => a.owner === "mia");
assert.equal(row.owner_display, "Mia Tran");
assert.equal(row.reader, "boss");
assert.equal(row.reader_display, null);
});
});
describe("Settings → Account (/api/me)", () => {
it("shows the account, and edits its own display name and email", async () => {
const leo = await login("leo", "member-pass-2");
const g = await call("GET", "/api/me/account", { cookie: leo });
assert.equal(g.body.mode, "server");
assert.equal(g.body.user.username, "leo");
assert.equal(g.body.workspaces, 1);
assert.equal(g.body.logins, 1);
const p = await call("PATCH", "/api/me/account", {
cookie: leo,
body: { displayName: "Leo N", email: "" },
});
assert.equal(p.status, 200);
assert.equal(p.body.user.displayName, "Leo N");
assert.equal(p.body.user.email, null);
const bad = await call("PATCH", "/api/me/account", { cookie: leo, body: { email: "x@" } });
assert.equal(bad.body.error.code, "EBADEMAIL");
});
it("changes its own password only with the old one, keeping this login", async () => {
const leo = await login("leo", "member-pass-2");
const other = await login("leo", "member-pass-2");
const wrong = await call("POST", "/api/me/password", {
cookie: leo,
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
});
assert.equal(wrong.body.error.code, "EREAUTH");
const ok = await call("POST", "/api/me/password", {
cookie: leo,
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
});
assert.equal(ok.status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
assert.ok(await login("leo", "leo-new-pass"));
});
it("signs out other devices", async () => {
const a = await login("leo", "leo-new-pass");
const b = await login("leo", "leo-new-pass");
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
assert.ok(r.body.ended >= 1);
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
assert.equal((await call("GET", "/api/me/account", { cookie: b })).status, 401);
});
it("refuses a pane token: an agent may not change its owner's login", async () => {
const pane = auth.newPaneToken();
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', '/w/leo', 'running', ?, ?)`
).run(`agenthub-lane-${ids.leoLane}`, ids.leoLane, pane.hash);
const r = await call("POST", "/api/me/sign-out-others", {
headers: { [auth.PANE_HEADER]: pane.token },
});
assert.notEqual(r.status, 200);
});
});
@@ -0,0 +1,105 @@
/**
* @file server-s3-onboarded.test.js
* @description S3: a member's Claude pane on a pool account (or an AI Proxy)
* must not stop at the first-run "Select login method" screen a fresh home
* shows even with CLAUDE_CODE_OAUTH_TOKEN set — `run-as.markClaudeOnboarded`
* adds `hasCompletedOnboarding: true` to the member's `~/.claude.json`, AS the
* member (write-as, played by fixtures/fake-sudo.js), keeping every other key
* and leaving a file it cannot trust alone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-onboarded-")));
const USERS = path.join(TMP, "users");
const HOME = path.join(USERS, "mia");
const FILE = path.join(HOME, ".claude.json");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
let acct;
before(async () => {
fs.mkdirSync(HOME, { recursive: true });
// The member's uid is this test's own, so its files pass the owner check.
const uid = process.getuid();
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
runAs.__resetCache();
linux.__setRunImpl(() => "ok");
runAs.__setExecImpl(sudo.exec);
process.env.AGENTHUB_MODE = "server";
await auth.createUser("mia", "member-pass-1", "developer");
acct = runAs.accountForPath(HOME);
assert.ok(acct, "mia is a provisioned member");
});
after(() => {
delete process.env.AGENTHUB_MODE;
});
beforeEach(() => {
fs.rmSync(FILE, { force: true });
sudo.calls.length = 0;
});
describe("markClaudeOnboarded", () => {
it("creates ~/.claude.json, 0600, as the member", () => {
assert.equal(runAs.markClaudeOnboarded(acct), true);
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), { hasCompletedOnboarding: true });
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
assert.ok(sudo.calls.some((c) => c.includes(FILE) && c.includes("private")));
});
it("adds only the flag, keeping the member's own state", () => {
fs.writeFileSync(FILE, JSON.stringify({ theme: "light", projects: { "/x": {} } }));
assert.equal(runAs.markClaudeOnboarded(acct), true);
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), {
theme: "light",
projects: { "/x": {} },
hasCompletedOnboarding: true,
});
});
it("writes nothing when the member is already onboarded", () => {
fs.writeFileSync(FILE, JSON.stringify({ hasCompletedOnboarding: true }));
assert.equal(runAs.markClaudeOnboarded(acct), false);
assert.equal(sudo.calls.length, 0);
});
it("leaves a file that is not JSON, or not a regular file, alone", () => {
fs.writeFileSync(FILE, "{ half-written");
assert.equal(runAs.markClaudeOnboarded(acct), false);
assert.equal(fs.readFileSync(FILE, "utf8"), "{ half-written");
fs.rmSync(FILE);
fs.mkdirSync(FILE);
assert.equal(runAs.markClaudeOnboarded(acct), false);
fs.rmdirSync(FILE);
assert.equal(sudo.calls.length, 0);
});
it("never writes through a link the member left at the name", () => {
const target = path.join(TMP, "elsewhere.json");
fs.writeFileSync(target, "{}");
fs.symlinkSync(target, FILE);
runAs.markClaudeOnboarded(acct);
assert.equal(fs.readFileSync(target, "utf8"), "{}");
});
});
@@ -226,6 +226,39 @@ describe("a developer browses and lists branches only in their own folder", () =
assert.equal(admin.status, 200);
});
it("an admin starts in their own home, and opens the host's only with host=1", async () => {
// No Linux account: the host's home, and no Server folder tick.
const plain = await call("boss", "GET", "/api/lanes/browse");
assert.equal(plain.status, 200);
assert.equal(plain.body.path, os.homedir());
assert.equal(plain.body.hostHome, undefined);
// With one (ah-boss, home <USERS>/boss): their own home first.
const BOSS = path.join(USERS, "boss");
fs.mkdirSync(path.join(BOSS, "work"), { recursive: true });
const passwd = fs.readFileSync(process.env.AGENTHUB_PASSWD, "utf8");
const uid = process.getuid();
fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd + `ah-boss:x:${uid}:${uid}::/:/bin/sh\n`);
runAs.__resetCache();
try {
const own = await call("boss", "GET", "/api/lanes/browse");
assert.equal(own.status, 200, JSON.stringify(own.body));
assert.equal(own.body.path, BOSS);
assert.equal(own.body.home, BOSS);
assert.equal(own.body.hostHome, os.homedir());
assert.ok(own.body.parent, "an admin is not held to their home");
const host = await call("boss", "GET", "/api/lanes/browse?host=1");
assert.equal(host.body.path, os.homedir());
assert.equal(host.body.home, os.homedir());
// A developer's host=1 changes nothing: still their own folder.
const mia = await call("mia", "GET", "/api/lanes/browse?host=1");
assert.equal(mia.body.path, MIA);
assert.equal(mia.body.hostHome, undefined);
} finally {
fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd);
runAs.__resetCache();
}
});
it("hidden=1 shows dot entries, never the logins at the top of their home", async () => {
fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true });
fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true });