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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user