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
+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");
});
});