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 });
+9
View File
@@ -1815,6 +1815,15 @@ try {
`);
}
}
// An account's own display name and email (the Users page's Edit user and
// Settings → Account). Additive; after the rebuild above, which predates them.
for (const col of ["display_name", "email"]) {
try {
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
} catch {
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
}
}
migrationLock.exec("COMMIT");
migrationLock.close();
+12
View File
@@ -772,6 +772,18 @@ function startBackgroundServices() {
}, HEAL_MS);
if (healTimer.unref) healTimer.unref();
}
// Claude pool logins (claude-pool.js, kind `login`): the server is the one
// holder that refreshes them, ahead of every member home it placed one in,
// and pushes the new pair there. Off with DASHBOARD_POOL_REFRESH_MS=0.
const POOL_REFRESH_MS = Number(process.env.DASHBOARD_POOL_REFRESH_MS ?? 60_000);
if (Number.isFinite(POOL_REFRESH_MS) && POOL_REFRESH_MS > 0) {
const poolTimer = setInterval(() => {
require("./lib/claude-pool")
.refreshLogins()
.catch((err) => console.warn("claude pool refresh failed:", err.message));
}, POOL_REFRESH_MS);
if (poolTimer.unref) poolTimer.unref();
}
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
+2 -1
View File
@@ -51,7 +51,8 @@ function listAudit(limit = 100) {
const n = Math.min(Math.max(Number(limit) || 100, 1), 1000);
return sql(
`SELECT a.id, a.at, a.method, a.path, a.session_id,
r.username AS reader, o.username AS owner
r.username AS reader, o.username AS owner,
r.display_name AS reader_display, o.display_name AS owner_display
FROM audit_log a
LEFT JOIN users r ON r.id = a.user_id
LEFT JOIN users o ON o.id = a.owner_id
+105 -4
View File
@@ -18,6 +18,10 @@
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled).
* An account also carries an optional display name and email
* (`validateProfile`), which its owner edits in Settings → Account without
* that step-up; changing their OWN password there needs the old one and
* keeps the login it was changed from (`setPassword`'s `keepToken`).
*
* This is login, not isolation: every member still sees every workspace and
* session, and every pane runs as the one host account (phases S2/S3 of the
@@ -60,6 +64,8 @@ const ADMIN_WRITE_PREFIXES = [
"/reports",
"/pets",
"/users",
// The Claude/Codex account pool: tokens every member's panes run on.
"/accounts",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
];
@@ -129,7 +135,57 @@ function validatePassword(password) {
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
const publicUser = (u) =>
u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) };
u && {
id: u.id,
username: u.username,
role: u.role,
disabled: Boolean(u.disabled),
displayName: u.display_name ?? null,
email: u.email ?? null,
createdAt: u.created_at ?? null,
};
const MAX_DISPLAY_NAME = 100;
const MAX_EMAIL = 254;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* The profile fields present in `body`, normalized: trimmed, `""`/`null` ⇒
* null (clearing is legitimate). Fields absent from `body` are absent from the
* result, so a caller writes only what was sent. Throws EBADFIELD/EBADEMAIL.
*/
function validateProfile(body) {
const out = {};
if (body.displayName !== undefined) {
const v = body.displayName;
if (v !== null && typeof v !== "string") throw err("EBADFIELD", "displayName: a string");
const n = (v || "").trim();
// eslint-disable-next-line no-control-regex
if (n.length > MAX_DISPLAY_NAME || /[\u0000-\u001f\u007f]/.test(n)) {
throw err("EBADFIELD", `displayName: at most ${MAX_DISPLAY_NAME} printable characters`);
}
out.display_name = n || null;
}
if (body.email !== undefined) {
const v = body.email;
if (v !== null && typeof v !== "string") throw err("EBADEMAIL", "email: a string");
const e = (v || "").trim();
if (e && (e.length > MAX_EMAIL || !EMAIL_RE.test(e))) {
throw err("EBADEMAIL", "email: not a valid address");
}
out.email = e || null;
}
return out;
}
/** Write `validateProfile`'s result for one account. */
function setProfile(userId, fields) {
for (const [col, value] of Object.entries(fields)) {
// `col` is one of validateProfile's two fixed names, never request text.
if (col !== "display_name" && col !== "email") continue;
sql(`UPDATE users SET ${col} = ? WHERE id = ?`).run(value, userId);
}
}
function listUsers() {
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
@@ -166,12 +222,33 @@ function requireUser(username) {
return u;
}
/** New password; every login of that user ends. */
async function setPassword(username, password) {
/** New password; every login of that user ends — but `keepToken`'s, when
* the user changed their own password from that login. */
async function setPassword(username, password, { keepToken } = {}) {
const u = requireUser(username);
validatePassword(password);
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
endSessions(u.id, keepToken);
}
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
function endSessions(userId, exceptToken) {
if (exceptToken) {
return sql("DELETE FROM auth_sessions WHERE user_id = ? AND token_hash != ?").run(
userId,
sha256(exceptToken)
).changes;
}
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
}
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
function loginStats(userId) {
const r = sql(
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
WHERE user_id = ? AND expires_at > ?`
).get(userId, new Date().toISOString());
return { logins: r.n, lastLoginAt: r.last || null };
}
function setRole(username, role) {
@@ -232,6 +309,23 @@ function assignWorkspace(laneId, username) {
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
}
/** Take a workspace back from the user who owns it: it is left with no owner
* (only staff see it) until it is given to someone. ENOTOWNED when `username`
* does not own it — this never takes one from a third user. */
function unassignWorkspace(laneId, username) {
const u = requireUser(username);
const lane = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
if (!lane) throw err("ENOLANE", `no workspace ${laneId}`);
if (lane.owner_id !== u.id) {
throw err("ENOTOWNED", `workspace ${laneId} is not ${u.username}'s`);
}
sql("UPDATE lanes SET owner_id = NULL WHERE id = ? AND owner_id = ?").run(Number(laneId), u.id);
}
/** The workspaces `userId` owns: `[{id, title, cwd}]`. */
const ownedWorkspaces = (userId) =>
sql("SELECT id, title, cwd FROM lanes WHERE owner_id = ? ORDER BY id").all(userId);
const hasAdmin = () =>
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
@@ -510,6 +604,13 @@ module.exports = {
setRole,
setDisabled,
assignWorkspace,
unassignWorkspace,
ownedWorkspaces,
validateProfile,
setProfile,
validatePassword,
endSessions,
loginStats,
hasAdmin,
userFromRequest,
login,
+20 -13
View File
@@ -108,25 +108,31 @@ async function refreshProfile(name) {
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
}
const fresh = await exchangeRefresh(creds);
profiles.writeProfile(name, { ...stored, credentials: fresh });
return fresh.accessToken;
}
/**
* One refresh-token exchange: `creds` (a `claudeAiOauth` block) with its new
* access token, refresh token and expiry. The CALLER must persist the result —
* Anthropic retires the refresh token it was handed (see `refreshProfile`).
*/
async function exchangeRefresh(creds) {
const data = await postJson(TOKEN_URL, {
grant_type: "refresh_token",
refresh_token: creds.refreshToken,
client_id: CLIENT_ID,
});
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
profiles.writeProfile(name, {
...stored,
credentials: {
...creds,
accessToken: data.access_token,
// Both are rotated by the exchange; keeping the old refresh token here
// would pin the profile to a credential that no longer works.
refreshToken: data.refresh_token || creds.refreshToken,
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
},
});
return data.access_token;
return {
...creds,
accessToken: data.access_token,
// Both are rotated by the exchange; keeping the old refresh token here
// would pin the profile to a credential that no longer works.
refreshToken: data.refresh_token || creds.refreshToken,
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
};
}
/** The two windows the UI reports, flattened out of a response that also
@@ -245,6 +251,7 @@ module.exports = {
fetchOwner,
liveOwner,
refreshProfile,
exchangeRefresh,
CLIENT_ID,
USAGE_URL,
TOKEN_URL,
+323 -14
View File
@@ -21,6 +21,28 @@
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
*
* Two kinds of account:
* - `token`: a `claude setup-token` token, given to the pane as
* `CLAUDE_CODE_OAUTH_TOKEN` (any pane, host or member).
* - `login`: a real Claude login (`claudeAiOauth` + `oauthAccount`), moved
* in from a saved switch-user profile (cc-profiles.js) or pasted from a
* `.credentials.json`. Claude Code reads it from no env var, so — like a
* Codex ChatGPT login — it is written as `~/.claude/.credentials.json`
* into a MEMBER's own home (S3), as the member (write-as, 0600), just
* before the pane starts: never into a host pane's home (the host's own
* login — unless that login itself was moved in, `hostHolds`, and the
* host's home is then one more placement), never over a login AgentHub
* did not place, never a second
* account into a home where a live pane of that member runs on one.
* Its refresh token is SINGLE USE, so the server is the one that
* refreshes it (`refreshLogins`, `REFRESH_AHEAD_MS` before expiry) and
* pushes the new pair into every home it placed the login in — Claude
* Code re-reads its credentials before refreshing and takes tokens
* "another process landed". A home whose Claude refreshed first is copied
* back, but only after Anthropic confirms the token is the same account
* (`fetchOwner`): a member could otherwise plant a login every other
* member's panes would get.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -34,6 +56,8 @@ const TOKEN_LIFE_MS = 365 * 864e5;
const QUOTA_TTL_MS = 5 * 60 * 1000;
const QUOTA_FRESH_MS = 30 * 60 * 1000;
const BLOCK_PERCENT = 90;
const REFRESH_AHEAD_MS = 30 * 60 * 1000;
const FUTURE_SLACK_MS = 5 * 60 * 1000;
const err = (code, message) => Object.assign(new Error(message), { code });
@@ -47,15 +71,33 @@ function validName(name) {
return n;
}
const isOauth = (o) =>
Boolean(o) &&
typeof o.accessToken === "string" &&
o.accessToken.length > 0 &&
typeof o.refreshToken === "string" &&
o.refreshToken.length > 0;
function readAccount(name) {
try {
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
return typeof a.token === "string" ? a : null;
if (a.kind === "login") return isOauth(a.oauth) ? a : null;
return typeof a.token === "string" ? { ...a, kind: "token" } : null;
} catch {
return null;
}
}
/** Rewrite one record (0600, through a temp file). */
function save(record) {
const tmp = `${fileOf(record.name)}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
fs.renameSync(tmp, fileOf(record.name));
}
/** The secret the usage endpoint takes for `a`. */
const bearerOf = (a) => (a.kind === "login" ? a.oauth.accessToken : a.token);
function allAccounts() {
let names = [];
try {
@@ -71,8 +113,11 @@ function allAccounts() {
.sort((a, b) => a.name.localeCompare(b.name));
}
const expiresAt = (a) => new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
const expired = (a) => Date.parse(expiresAt(a)) <= Date.now();
// A setup-token lives a year; a login lives as long as its refresh token works.
const expiresAt = (a) =>
a.kind === "login" ? null : new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
const expired = (a) =>
a.kind === "login" ? a.refreshError === "ESTALE" : Date.parse(expiresAt(a)) <= Date.now();
/** Add an account; refuses a name already in the pool. */
function addAccount(name, token) {
@@ -93,13 +138,200 @@ function addAccount(name, token) {
return { name: n, addedAt: record.addedAt, expiresAt: expiresAt(record) };
}
/**
* Add a real login (`claudeAiOauth`, plus the `oauthAccount`/`userID` Claude
* Code keeps in `.claude.json`); refuses a name, or an account, already in the
* pool. The caller hands over its copy: two holders of one refresh token log
* each other out, so a source that keeps using it must not be offered — with
* one exception, `hostHolds`: the login this machine's own Claude Code runs
* on, whose home then becomes one more placement (`HOST`) the server
* refreshes and pushes into like any member's.
*/
function addLoginAccount(name, { oauth, account = null, userID = null, hostHolds = false } = {}) {
const n = validName(name);
if (!isOauth(oauth)) {
throw err(
"EBADTOKEN",
"not a Claude login — it needs claudeAiOauth with an accessToken and a refreshToken"
);
}
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
const uuid = account?.accountUuid;
const twin =
uuid && allAccounts().find((a) => a.kind === "login" && a.account?.accountUuid === uuid);
if (twin) throw err("EEXISTS", `that Claude account is already in the pool as ${twin.name}`);
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
fs.chmodSync(poolDir(), 0o700);
const record = {
name: n,
kind: "login",
oauth,
account,
userID,
addedAt: new Date().toISOString(),
placed: hostHolds ? { [HOST]: oauth.refreshToken } : {},
};
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
quotaCache.delete(n);
return { name: n, kind: "login", addedAt: record.addedAt, email: account?.emailAddress ?? null };
}
function removeAccount(name) {
const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
const a = readAccount(n);
if (!a) throw err("ENOACCOUNT", `no account ${n} in the pool`);
// Take the login back out of every home it is still ours in.
if (a.kind === "login") {
for (const [member, acct] of placedHomes(a)) {
// The host keeps its own login: removing it would log this machine out.
if (acct.host || homeCreds(acct)?.refreshToken !== a.placed[member]) continue;
try {
runAs().writeAs(acct, credsPath(acct), "remove");
} catch {
/* best effort: the member can still /logout */
}
}
}
fs.rmSync(fileOf(n));
quotaCache.delete(n);
}
// ── A member's .credentials.json (kind `login`) ────────────────────────────
const runAs = () => require("./run-as");
/** The placement key of this machine's own Claude Code (`hostHolds`). */
const HOST = "@host";
const HOST_ACCT = { name: HOST, host: true };
const credsPath = (acct) =>
acct.host
? path.join(getClaudeHome(), ".credentials.json")
: path.join(acct.home, ".claude", ".credentials.json");
/** The whole `.credentials.json` of a placement (a member's read guarded), or null. */
function readCredsFile(acct) {
try {
const text = acct.host
? fs.readFileSync(credsPath(acct), "utf8")
: runAs().readFileSafe(credsPath(acct), "utf8", { max: 1 << 20 });
const c = JSON.parse(text);
return c && typeof c === "object" && !Array.isArray(c) ? c : null;
} catch {
return null;
}
}
/** The `claudeAiOauth` of a placement, or null. */
function homeCreds(acct) {
const c = readCredsFile(acct);
return c && typeof c.claudeAiOauth === "object" ? c.claudeAiOauth : null;
}
/** `[key, account]` for every home `a` is placed in that still runs as its member (or the host). */
function placedHomes(a) {
const out = [];
for (const member of Object.keys(a.placed || {})) {
if (member === HOST) {
out.push([HOST, HOST_ACCT]);
continue;
}
try {
const acct = runAs().accountForUser(member);
if (acct) out.push([member, acct]);
} catch {
/* a disabled member's home is left as it is */
}
}
return out;
}
/** Whether `member`'s credentials are still the pair AgentHub last wrote there for `a`. */
const ownsHome = (a, member, disk) =>
a.kind === "login" && Boolean(disk) && disk.refreshToken === a.placed?.[member];
/**
* Write `a`'s login into a placement — a member's home as the member, the
* host's directly (0600, through a temp file) — keeping every other key of
* the file (`mcpOAuth`…), and remember the pair.
*/
function writeLogin(a, acct) {
const text = `${JSON.stringify({ ...(readCredsFile(acct) || {}), claudeAiOauth: a.oauth }, null, 2)}\n`;
if (acct.host) {
const tmp = `${credsPath(acct)}.agenthub-tmp`;
fs.writeFileSync(tmp, text, { mode: 0o600 });
fs.renameSync(tmp, credsPath(acct));
} else {
runAs().mkdirAs(acct, path.dirname(credsPath(acct)));
runAs().writeAs(acct, credsPath(acct), "private", text);
}
return { ...a, placed: { ...(a.placed || {}), [acct.name]: a.oauth.refreshToken } };
}
const refreshing = new Set();
/**
* Keep every login alive, run on a beat: copy back a home's newer pair (only
* once Anthropic says it is the same account), refresh what expires within
* `REFRESH_AHEAD_MS` — here, the one holder that may spend the refresh
* token — and push the newest pair into every home it is placed in. A home
* whose credentials are no longer ours (the member logged in themselves, or
* out) is dropped from the placement and never written again.
*/
async function refreshLogins() {
const usage = require("./cc-usage");
for (const stale of allAccounts().filter((x) => x.kind === "login")) {
if (refreshing.has(stale.name)) continue;
refreshing.add(stale.name);
try {
let a = stale;
const homes = placedHomes(a);
// 1. A home whose own Claude refreshed first holds the only live pair.
for (const [member, acct] of homes) {
const disk = homeCreds(acct);
if (!disk || ownsHome(a, member, disk)) continue;
const newer =
isOauth(disk) &&
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
const owner = newer ? await usage.fetchOwner(disk.accessToken) : null;
const placed = { ...a.placed };
if (owner && owner.uuid === a.account?.accountUuid) {
a = { ...a, oauth: { ...a.oauth, ...disk } };
placed[member] = disk.refreshToken;
} else {
delete placed[member]; // their own login now: never ours to write over
}
a = { ...a, placed };
save(a);
}
// 2. The server refreshes ahead of every home.
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
try {
a = { ...a, oauth: await usage.exchangeRefresh(a.oauth), refreshError: null };
} catch (e) {
a = { ...a, refreshError: e.code || "EUPSTREAM" };
}
save(a);
quotaCache.delete(a.name);
}
// 3. Every home still ours gets the newest pair.
for (const [member, acct] of placedHomes(a)) {
const disk = homeCreds(acct);
if (!ownsHome(a, member, disk) || disk.refreshToken === a.oauth.refreshToken) continue;
try {
a = writeLogin(a, acct);
save(a);
} catch (e) {
console.warn(`[claude-pool] ${a.name} → ${member}: ${e.message}`);
}
}
} catch (e) {
console.warn(`[claude-pool] ${stale.name}: ${e.message}`);
} finally {
refreshing.delete(stale.name);
}
}
}
// ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */
@@ -117,7 +349,7 @@ async function refreshQuotas({ force = false } = {}) {
}
inFlight.add(a.name);
try {
const u = await fetchUsage(a.token);
const u = await fetchUsage(bearerOf(a));
quotaCache.set(a.name, { at: Date.now(), ...u });
} catch (e) {
// Keep the last good windows: a hiccup must not unblock a full account.
@@ -203,6 +435,9 @@ function poolStatus() {
const percent = quotaPercent(a.name);
return {
name: a.name,
kind: a.kind,
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
addedAt: a.addedAt,
expiresAt: expiresAt(a),
expired: expired(a),
@@ -223,13 +458,44 @@ function poolStatus() {
});
}
/**
* The account a new Claude pane should run on — `{name, token}` — or null when
* the pool has no usable account. Synchronous (a spawn cannot wait on the
* network): it ranks on the cached quota and starts a background re-read.
*/
function pickAccount() {
const usable = allAccounts().filter((a) => !expired(a));
function liveInHome(name, member) {
const { db } = require("../db");
return db
.prepare(
`SELECT lane_id FROM dashboard_runs
WHERE tool = 'claude' AND account = ? AND status IN ('running', 'spawning')
AND lane_id IS NOT NULL`
)
.all(name)
.some((r) => {
try {
return runAs().accountForLane(r.lane_id)?.name === member.name;
} catch {
return false; // a gone member's lane is not this (enabled) member's
}
});
}
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */
function pick(member) {
let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
if (!member) {
// A host pane's home holds the host's own login.
usable = usable.filter((a) => a.kind === "token");
} else {
const disk = homeCreds(member);
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
if (!placed && disk) {
// A login AgentHub did not place there is the member's own: keep it.
usable = usable.filter((a) => a.kind === "token");
} else {
// One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
if (live) return live;
}
}
if (usable.length === 0) return null;
refreshQuotas().catch(() => {});
const ranked = usable
@@ -251,17 +517,60 @@ function pickAccount() {
}
return x.a.name.localeCompare(y.a.name);
});
const { name, token } = ranked[0].a;
return { name, token };
return ranked[0].a;
}
/**
* The account a new Claude pane should run on — `{name, kind, token?}` — or
* null when the pool has nothing this pane may use. Synchronous (a spawn
* cannot wait on the network): it ranks on the cached quota and starts a
* background re-read. A `login` is only ever offered for a member.
*/
function pickAccount(member = null) {
const a = pick(member);
if (!a) return null;
return a.kind === "login"
? { name: a.name, kind: "login" }
: { name: a.name, kind: "token", token: a.token };
}
/**
* What a new Claude pane needs from the pool, or null: `{name, env, place}`.
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
* before the pane starts and writes a login into the member's home.
*/
function forSpawn(member = null) {
const a = pick(member);
if (!a) return null;
if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
}
return {
name: a.name,
env: {},
place: () => {
const record = readAccount(a.name) || a;
save(writeLogin(record, member));
runAs().markClaudeOnboarded(member, {
...(record.account ? { oauthAccount: record.account } : {}),
...(record.userID ? { userID: record.userID } : {}),
});
},
};
}
module.exports = {
BLOCK_PERCENT,
REFRESH_AHEAD_MS,
addAccount,
addLoginAccount,
removeAccount,
poolStatus,
pickAccount,
forSpawn,
refreshQuotas,
refreshLogins,
__read: readAccount,
poolDir,
__quotaCache: quotaCache,
};
+7 -5
View File
@@ -736,10 +736,12 @@ function inferredDisplayStage(lane) {
return lane.detected_stage;
}
/** The owner's username — the staff sidebar groups workspaces by it. */
function ownerName(id) {
if (id == null) return null;
return db.prepare("SELECT username FROM users WHERE id = ?").get(id)?.username ?? null;
/** The owner's username and display name — the staff sidebar groups
* workspaces by owner and shows the display name when one is set. */
function ownerNames(id) {
if (id == null) return { owner_name: null, owner_display_name: null };
const u = db.prepare("SELECT username, display_name FROM users WHERE id = ?").get(id);
return { owner_name: u?.username ?? null, owner_display_name: u?.display_name ?? null };
}
function lanePayload(lane, ageSec = null) {
@@ -755,7 +757,7 @@ function lanePayload(lane, ageSec = null) {
delete row.instructions;
return {
...row,
owner_name: ownerName(lane.owner_id),
...ownerNames(lane.owner_id),
pipeline_name: pipeline.name,
pipeline_skill: pipeline.skill || "",
pipeline_nodes:
+19 -4
View File
@@ -463,8 +463,12 @@ function spawnRun(args) {
// A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge.
// A real login (kind `login`) is written into a MEMBER's home by place();
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
const poolAccount =
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
entry.launcher === "claude" && !proxyProfile
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
: null;
// Codex likewise, from its own pool (codex-pool.js): an API key through
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
const codexPool =
@@ -629,7 +633,7 @@ function spawnRun(args) {
...proxySpawn.env,
// The pool account's setup-token: outranks the machine's `/login` for
// this process only. Through tmux `-e`, never typed into the pane.
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
...(poolAccount ? poolAccount.env : {}),
...(codexPool ? codexPool.env : {}),
// Server mode: the CLI, MCP server and statusline in this pane act as the
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
@@ -659,8 +663,19 @@ function spawnRun(args) {
if (timer.unref) timer.unref();
};
if (!tmux.hasSession(id)) {
// What a pane needs in its account's home before it starts: a Codex pool
// login, and — for a member's Claude on a pool account or an AI Proxy — the
// onboarding flag, or a fresh home asks for a login the token makes moot.
const prepareHome = () => {
codexPool?.place();
poolAccount?.place();
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
require("./run-as").markClaudeOnboarded(member);
}
};
if (!tmux.hasSession(id)) {
prepareHome();
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
record();
schedulePaste();
@@ -671,7 +686,7 @@ function spawnRun(args) {
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
// instead of erroring or opening a second session.
codexPool?.place();
prepareHome();
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
record();
schedulePaste();
+31
View File
@@ -299,6 +299,36 @@ function scratchFile(cwd, prefix) {
};
}
/**
* Mark a member's Claude Code as onboarded (`hasCompletedOnboarding: true` in
* `~/.claude.json`), so a pane that brings its own credentials — a pool
* account's `CLAUDE_CODE_OAUTH_TOKEN`, an AI Proxy — starts at the prompt
* instead of the first-run "Select login method" screen, which a fresh home
* shows even with the token set. Only that key — plus, for a pool login,
* `extra` (its `oauthAccount`/`userID`, so Claude Code names the account it
* runs as) — is added, AS the member (`write-as private`); a file that is not
* the member's own regular file or not JSON is left alone. Best effort: false
* when nothing was written.
*/
function markClaudeOnboarded(acct, extra = {}) {
const file = path.join(acct.home, ".claude.json");
let state = {};
try {
state = JSON.parse(readFileSafe(file, "utf8"));
} catch (e) {
if (e.code !== "ENOENT") return false;
}
if (!state || typeof state !== "object" || Array.isArray(state)) return false;
const next = { ...state, ...extra, hasCompletedOnboarding: true };
if (JSON.stringify(next) === JSON.stringify(state)) return false;
try {
writeAs(acct, file, "private", `${JSON.stringify(next, null, 2)}\n`);
return true;
} catch {
return false;
}
}
/** A user's account by AgentHub username (S3, provisioned), or null. */
function accountForUser(username) {
const conf = linux.s3Enabled() ? linux.serverConf() : null;
@@ -541,6 +571,7 @@ module.exports = {
memberNameForUid,
sudoArgv,
memberPaneEnv,
markClaudeOnboarded,
MEMBER_PATH,
writeFileAs,
scratchFile,
+209 -2
View File
@@ -4,10 +4,14 @@
* - POST /api/auth/login (Auth)
* - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
* - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
* (Auth) — Settings → Account: the account's own profile, password and logins
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -38,6 +42,21 @@ const schemas = {
username: { type: "string" },
role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
displayName: { type: "string", nullable: true },
email: { type: "string", nullable: true },
createdAt: { type: "string", nullable: true },
},
},
MyAccount: {
type: "object",
required: ["mode"],
properties: {
mode: { type: "string", enum: ["local", "server"] },
user: { $ref: "#/components/schemas/AuthUser" },
workspaces: { type: "integer" },
sessions: { type: "integer" },
logins: { type: "integer", description: "Unexpired logins of the account" },
lastLoginAt: { type: "string", nullable: true },
},
},
AuthMe: {
@@ -88,6 +107,9 @@ const changeResponses = {
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
};
const accountBody = {
"application/json": { schema: { $ref: "#/components/schemas/MyAccount" } },
};
const prefsBody = {
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
};
@@ -182,6 +204,85 @@ const paths = {
responses: { 200: { description: "Marked", content: prefsBody }, 401: err("EAUTH") },
},
},
"/api/me/account": {
get: {
tags: ["Auth"],
operationId: "getMyAccount",
summary: 'Settings → Account: the signed-in account (local mode: {mode: "local"})',
responses: {
200: { description: "Account", content: accountBody },
401: err("EAUTH"),
},
},
patch: {
tags: ["Auth"],
operationId: "updateMyAccount",
summary:
'The account\'s own display name and email (""/null clears). Login cookie only, audited',
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
displayName: { type: "string", nullable: true, maxLength: 100 },
email: { type: "string", nullable: true, maxLength: 254 },
},
},
},
},
},
responses: {
200: { description: "Updated", content: accountBody },
400: err("EBADFIELD / EBADEMAIL"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
},
},
},
"/api/me/password": {
post: {
tags: ["Auth"],
operationId: "changeMyPassword",
summary:
"Change the account's own password: the current one required (throttled); this login stays, every other ends",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["currentPassword", "newPassword"],
properties: {
currentPassword: { type: "string" },
newPassword: { type: "string", minLength: 1 },
},
},
},
},
},
responses: {
200: { description: "Changed ({ok: true})" },
400: err("EBADPASSWORD"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("EREAUTH (current password wrong) / ELOCALMODE"),
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
},
},
},
"/api/me/sign-out-others": {
post: {
tags: ["Auth"],
operationId: "signOutMyOtherLogins",
summary: "End every login of the account but this one; answers {ended}",
responses: {
200: { description: "Ended" },
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
},
},
},
"/api/users": {
get: {
tags: ["Auth"],
@@ -263,17 +364,38 @@ const paths = {
},
},
"/api/users/{id}": {
get: {
tags: ["Auth"],
operationId: "getServerUser",
summary: "One account with its logins and the workspaces it owns (admin/analyst)",
parameters: [idParam],
responses: {
200: { description: "Account" },
403: err("EADMIN — a developer"),
404: err("ENOUSER"),
},
},
patch: {
tags: ["Auth"],
operationId: "updateServerUser",
summary:
"Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)",
"Admin (Edit user): display name, email, role, disabled, a new password, workspaces given or taken back, sign out everywhere — every field validated before any is written. Never your own role or disable (ESELF)",
parameters: [idParam],
requestBody: changeBody({
displayName: { type: "string", nullable: true, maxLength: 100 },
email: { type: "string", nullable: true, maxLength: 254 },
role: { type: "string", enum: ["admin", "analyst", "developer"] },
disabled: { type: "boolean" },
password: { type: "string", minLength: 1 },
addWorkspaces: { type: "array", items: { type: "integer" } },
removeWorkspaces: { type: "array", items: { type: "integer" } },
signOut: { type: "boolean", enum: [true] },
}),
responses: changeResponses,
responses: {
...changeResponses,
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
},
},
},
"/api/users/{id}/password": {
@@ -322,6 +444,8 @@ const paths = {
session_id: { type: "string", nullable: true },
reader: { type: "string", nullable: true },
owner: { type: "string", nullable: true },
reader_display: { type: "string", nullable: true },
owner_display: { type: "string", nullable: true },
},
},
},
@@ -401,6 +525,89 @@ const paths = {
},
},
},
post: {
tags: ["Auth"],
operationId: "addPoolAccount",
summary:
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
name: { type: "string", description: "Defaults to the profile's name" },
profile: {
type: "string",
description: "Claude: a saved switch-user profile, moved into the pool",
},
credentials: {
type: "string",
description: "Claude: the text of a .credentials.json",
},
token: {
type: "string",
description: "Claude: a setup-token; Codex: an API key or auth.json text",
},
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
201: { description: "`{account: {name, addedAt, …}}` — no token" },
400: err("EBADTOOL, EBADNAME, EBADTOKEN"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
409: err(
"EEXISTS — the name or account is taken; EACTIVE — the host's CLI uses that profile"
),
502: err("EUPSTREAM — Anthropic could not be reached"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/profiles": {
get: {
tags: ["Auth"],
operationId: "listPoolProfiles",
summary: "Saved switch-user profiles an admin may move into the Claude pool (no token)",
responses: {
200: { description: "`{profiles: [{name, label, email, subscriptionType, active}]}`" },
},
},
},
"/api/accounts/{name}": {
delete: {
tags: ["Auth"],
operationId: "removePoolAccount",
summary: "Admin: remove an account from the pool, with their own password again",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: { description: "`{ok: true}`" },
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOACCOUNT"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/auth/me": {
get: {
+187 -2
View File
@@ -4,14 +4,31 @@
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
* key or a token. `?force=1` re-reads every quota first. Accounts are added and
* removed only from the host's shell (`agenthub accounts …`).
* key or a token. `?force=1` re-reads every quota first.
*
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
* `DELETE /:name` (`{tool}`). A Claude account is one of: `profile` — a login
* saved by the switch-user profiles (cc-profiles.js), MOVED into the pool (its
* single-use refresh token may have only one holder, so the profile is
* deleted; the account the host's own CLI is logged into moves in with its
* live login and the host's home becomes one more placement the server
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
* one is an OpenAI API key or the text of an `auth.json` (`token`).
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
* they need the admin's own password again (`currentPassword`), are audited
* (the account NAME only), and refuse in local mode. The secret only goes IN:
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const pool = require("../lib/claude-pool");
const codexPool = require("../lib/codex-pool");
const auth = require("../lib/auth");
const { recordAccountChange } = require("../lib/audit");
const profiles = require("../lib/cc-profiles");
const router = Router();
@@ -29,4 +46,172 @@ router.get("/", async (req, res) => {
}
});
/** Saved switch-user profiles an admin may move into the pool — never a token. */
router.get("/profiles", (_req, res) => {
try {
res.json({
profiles: profiles.listProfiles().items.map((p) => ({
name: p.name,
label: p.label,
email: p.email,
subscriptionType: p.subscriptionType,
active: p.active,
})),
});
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPROFILES", message: e.message } });
}
});
const STATUS = {
ENOTFOUND: 404,
EACTIVE: 409,
ELOCALMODE: 403,
EREAUTH: 403,
EBADTOOL: 400,
EBADNAME: 400,
EBADTOKEN: 400,
ENOACCOUNT: 404,
EEXISTS: 409,
ETOOMANY: 429,
ESTALE: 409, // a pasted login whose refresh token was already spent
EUPSTREAM: 502,
};
/** One pool change: server mode, the admin's password confirmed, audited. */
function poolChange(handler) {
return async (req, res) => {
try {
if (!auth.isServerMode() || !req.user) {
throw Object.assign(new Error("the pool is changed from the host's shell in local mode"), {
code: "ELOCALMODE",
});
}
const body = req.body && typeof req.body === "object" ? req.body : {};
if (body.tool !== "claude" && body.tool !== "codex") {
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
}
await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body);
recordAccountChange(req.user.id, null, req.method, out.action);
res.status(out.status || 200).json(out.json);
} catch (e) {
const status = STATUS[e.code];
if (!status) return res.status(500).json({ error: { message: e.message } });
res.status(status).json({ error: { code: e.code, message: e.message } });
}
};
}
/**
* A pasted `.credentials.json` (or Keychain item) → `{oauth, account}`. The
* file names no account, so Anthropic is asked whose token it is (refreshing
* an expired one first — what the exchange returns is what gets stored):
* the pool copies a member's refresh back only when it is that same account.
*/
async function pastedLogin(text) {
let parsed;
try {
parsed = JSON.parse(String(text || ""));
} catch {
throw Object.assign(new Error("not valid JSON — paste the whole .credentials.json"), {
code: "EBADTOKEN",
});
}
let oauth = parsed?.claudeAiOauth || parsed;
if (!oauth?.accessToken || !oauth?.refreshToken) return { oauth }; // addLoginAccount says why
const usage = require("../lib/cc-usage");
let owner = await usage.fetchOwner(oauth.accessToken);
if (!owner) {
oauth = await usage.exchangeRefresh(oauth);
owner = await usage.fetchOwner(oauth.accessToken);
}
if (!owner) {
throw Object.assign(new Error("Anthropic did not accept this login"), { code: "EBADTOKEN" });
}
return { oauth, account: { accountUuid: owner.uuid, emailAddress: owner.email } };
}
/**
* Move a saved profile into the pool: the pool becomes its one holder. The
* account this machine's own Claude Code is logged into moves in with the
* LIVE login (the saved copy's refresh token is spent once the CLI refreshed)
* and keeps running here: the host's home becomes one more placement the
* server refreshes and pushes into. A macOS Keychain login cannot be written
* that way (EACTIVE).
*/
function fromProfile(name, poolName) {
const stored = profiles.readProfile(name);
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
const active = profiles.listProfiles().items.find((p) => p.name === name)?.active;
let oauth = stored.credentials;
if (active) {
const live = profiles.readActiveAuth();
if (live.keychain || !live.oauth) {
throw Object.assign(
new Error(
"this machine's Claude Code keeps that login in the macOS Keychain — switch it to another profile first"
),
{ code: "EACTIVE" }
);
}
oauth = live.oauth;
}
const account = pool.addLoginAccount(poolName || name, {
oauth,
account: stored.account || null,
userID: stored.userID || null,
hostHolds: Boolean(active),
});
// The only saved copy of the live account is not deletable (EACTIVE) — and
// need not be: removing the file is enough, the pool holds the login now.
require("node:fs").rmSync(require("node:path").join(profiles.profilesDir(), `${name}.json`), {
force: true,
});
return account;
}
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
function codexSecret(token) {
const text = String(token || "").trim();
if (!text.startsWith("{")) return { apiKey: text };
try {
return { auth: JSON.parse(text) };
} catch {
throw Object.assign(new Error("not valid JSON — paste the whole auth.json"), {
code: "EBADTOKEN",
});
}
}
router.post(
"/",
poolChange(async (_req, body) => {
const account =
body.tool === "codex"
? codexPool.addAccount(body.name, codexSecret(body.token))
: body.profile
? fromProfile(String(body.profile), body.name)
: body.credentials
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
: pool.addAccount(body.name, body.token);
return {
status: 201,
json: { account },
action: `pool: add ${body.tool} account ${account.name}`,
};
})
);
router.delete(
"/:name",
poolChange((req, body) => {
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
return {
json: { ok: true },
action: `pool: remove ${body.tool} account ${req.params.name}`,
};
})
);
module.exports = router;
+13
View File
@@ -22,14 +22,20 @@
* `GET /:name` — a route that returned one profile in full would be the one
* way an API key could leave this process.
*
* `GET /9router` only says whether a `9router` binary is on the server's PATH,
* so the page can offer a link to its own config UI; it never runs it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const express = require("express");
const proxies = require("../lib/ai-proxies");
const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply");
const { sameOriginGuard } = require("../lib/same-origin");
const { which } = require("../lib/ai-tools");
const { shellPath } = require("../lib/shell-path");
const router = express.Router();
@@ -74,6 +80,13 @@ router.get("/", (req, res) => {
}
});
// GET /api/ai-proxies/9router — is 9router installed here? `{ installed }`.
// A file lookup on PATH only; nothing is spawned.
router.get("/9router", (_req, res) => {
const pathStr = [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
res.json({ installed: Boolean(which("9router", pathStr)) });
});
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
// (a member's only choice); local mode has no user and always writes shared.
+22 -2
View File
@@ -569,6 +569,10 @@ router.post("/gc", sameOriginGuard, (req, res) => {
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
* own folder, where a member's logins live. Each entry carries its `mtime`
* (ms), and `home` is the folder the browser's Home place opens.
* An admin with a Linux account of their own (S3) starts in THEIR home, like
* any member, and the answer carries `hostHome` (the host account's home);
* `host=1` opens that one instead — a workspace there runs as the shared
* host account, so it is a deliberate tick in the dialog, never the default.
*/
router.get("/browse", (req, res) => {
// A developer browses only their own folder (by real path: a link in it
@@ -577,12 +581,22 @@ router.get("/browse", (req, res) => {
const own = scope.ownFolder(req);
const files = req.query.files === "1";
const hidden = req.query.hidden === "1";
// An admin is not held to a folder, but starts in their own home (S3).
let adminHome = null;
if (!own && req.user?.role === "admin") {
try {
adminHome = require("../lib/run-as").accountForUser(req.user.username)?.home || null;
} catch {
// No Linux account for this admin (EMEMBERGONE): the host's home, as before.
}
}
const startHome = adminHome && req.query.host !== "1" ? adminHome : own || os.homedir();
const raw =
typeof req.query.path === "string" && req.query.path.trim()
? files
? startDir(req.query.path)
: req.query.path
: own || os.homedir();
: startHome;
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
@@ -634,7 +648,13 @@ router.get("/browse", (req, res) => {
const top = path.dirname(resolved) === resolved || atOwnTop;
const parent = top ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries, home: own || os.homedir() });
res.json({
path: resolved,
parent,
entries,
home: startHome,
...(adminHome ? { hostHome: os.homedir() } : {}),
});
});
/**
+92
View File
@@ -15,6 +15,14 @@
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
*
* `/account` (server mode) is the signed-in account itself for Settings →
* Account: GET its profile, workspace/session counts and logins; PATCH its
* display name and email; `POST /password` changes its own password (the old
* one required, throttled like a step-up; every OTHER login ends);
* `POST /sign-out-others` ends every login but this one. These writes take the
* account from the login COOKIE only — a pane token (an agent acting as its
* owner) may not change the owner's login. Each change is audited.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -142,6 +150,90 @@ router.post("/onboarded", sameOriginGuard, (req, res) => {
res.json(read(key));
});
// ── The account itself (server mode) ─────────────────────────────────────────
const { sessionWhere } = require("../lib/scope");
const { recordAccountChange } = require("../lib/audit");
const ACCOUNT_STATUS = {
EBADFIELD: 400,
EBADEMAIL: 400,
EBADPASSWORD: 400,
EREAUTH: 403,
ETOOMANY: 429,
};
function accountView(userId) {
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
const w = sessionWhere({ user: { id: userId, role: "developer" } });
return {
mode: "server",
user: auth.publicUser(row),
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(userId).n,
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params).n,
...auth.loginStats(userId),
};
}
/** A write to the account: the cookie's own user, never a pane token's. */
function accountWrite(handler) {
return async (req, res) => {
if (!auth.isServerMode()) {
return res
.status(403)
.json({ error: { code: "ELOCALMODE", message: "no account in local mode" } });
}
const user = auth.userFromRequest(req);
if (!user) return unauth(res);
try {
const body = req.body && typeof req.body === "object" ? req.body : {};
const { out, action } = await handler(req, user, body);
recordAccountChange(user.id, user.id, req.method, `${user.username}: ${action}`);
res.json(out);
} catch (err) {
const status = ACCOUNT_STATUS[err.code];
if (!status) return res.status(500).json({ error: { message: err.message } });
res.status(status).json({ error: { code: err.code, message: err.message } });
}
};
}
router.get("/account", (req, res) => {
if (!auth.isServerMode()) return res.json({ mode: "local" });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
res.json(accountView(user.id));
});
router.patch(
"/account",
sameOriginGuard,
accountWrite(async (_req, user, body) => {
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
auth.setProfile(user.id, fields);
return { out: accountView(user.id), action: "own profile" };
})
);
router.post(
"/password",
sameOriginGuard,
accountWrite(async (req, user, body) => {
await auth.confirmPassword(user, body.currentPassword);
await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) });
return { out: { ok: true }, action: "own password changed" };
})
);
router.post(
"/sign-out-others",
sameOriginGuard,
accountWrite(async (req, user) => {
const ended = auth.endSessions(user.id, auth.cookieToken(req));
return { out: { ended }, action: `signed out ${ended} other login(s)` };
})
);
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
function followClaudeTheme(req, mode) {
if (mode !== "dark" && mode !== "light") return;
+65
View File
@@ -543,6 +543,71 @@ router.get("/:id/image", async (req, res) => {
}
});
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
// lies outside the workspace (a report written to ~/Documents, issue #76).
// The same authorization as `/image` — that event, never a pushed session,
// server mode only with S3 and a member's session only inside their own home
// on a file they own — plus: never a credential by name
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
// non-regular file, at most READ_TEXT_MAX bytes (the rest is `truncated`).
const READ_TEXT_MAX = 512 * 1024;
const sessionTouchedFile = db.prepare(
`SELECT 1 FROM events
WHERE session_id = ? AND event_type = 'PostToolUse'
AND tool_name IN ('Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit')
AND (json_extract(data, '$.tool_input.file_path') = ?
OR json_extract(data, '$.tool_input.notebook_path') = ?)
LIMIT 1`
);
router.get("/:id/file", async (req, res) => {
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
const session = stmts.getSession.get(req.params.id);
if (!session) return fail(404, "NOT_FOUND", "Session not found");
const s3 = require("../lib/linux-accounts").s3Enabled();
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
return fail(403, "EUNSUPPORTED", "not available for this session");
}
const file = typeof req.query.path === "string" ? req.query.path : "";
if (!path.isAbsolute(file)) return fail(400, "EBADPATH", "not an absolute path");
const { isSecretConfigPath } = require("../lib/cc-discovery");
const secret = (p) =>
isSecretConfigPath(p) || path.basename(p).toLowerCase() === "settings.local.json";
if (secret(file)) return fail(403, "ESECRET", "a credential file is never served");
const runAs = require("../lib/run-as");
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
return fail(404, "ENOTFOUND", "the session never touched that file");
}
if (!sessionTouchedFile.get(session.id, file, file)) {
return fail(404, "ENOTFOUND", "the session never touched that file");
}
let fh;
try {
// By what it RESOLVES to as well: a link named `notes.md` to a key file.
if (secret(await fs.promises.realpath(file))) {
return fail(403, "ESECRET", "a credential file is never served");
}
// NONBLOCK: a FIFO must not hang the event loop.
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
const st = await fh.stat();
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
return fail(404, "ENOTFOUND", "no such file");
}
const buf = Buffer.alloc(Math.min(st.size, READ_TEXT_MAX));
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
const head = buf.subarray(0, bytesRead);
if (head.subarray(0, 8000).includes(0)) return fail(415, "EBINARY", "not a text file");
res.set("Cache-Control", "no-store");
res.json({ text: head.toString("utf8"), truncated: st.size > READ_TEXT_MAX });
} catch {
if (!res.headersSent) fail(404, "ENOTFOUND", "no such file");
} finally {
await fh?.close();
}
});
/** The `/:id/stats` body — also frozen into a session snapshot. */
function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
+89 -11
View File
@@ -7,8 +7,12 @@
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js).
*
* The Users page's account changes — `POST /` (create), `PATCH /:id` (role,
* disabled), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
* `GET /:id` is one account with the workspaces it owns, for the Edit user form.
*
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
* user form: display name, email, role, disabled, a new password, workspaces
* given and taken back, sign out everywhere — every field validated before any
* is applied), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
* an admin never demotes or disables themselves (ESELF), every change is
@@ -69,6 +73,7 @@ router.get("/", (_req, res) => {
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
return {
...u,
...auth.loginStats(u.id),
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
.n,
@@ -85,6 +90,8 @@ router.get("/audit", (req, res) => {
const STATUS = {
EBADUSER: 400,
EBADEMAIL: 400,
ENOTOWNED: 409,
EBADPASSWORD: 400,
EBADROLE: 400,
EBADFIELD: 400,
@@ -132,6 +139,24 @@ function target(req) {
return u;
}
router.get("/:id", (req, res) => {
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
res.json({
user: { ...auth.publicUser(u), ...auth.loginStats(u.id) },
workspaces: auth.ownedWorkspaces(u.id),
});
});
/** A list of workspace ids from the body, or [] when absent. */
function laneIds(value, field) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.some((v) => !Number.isInteger(v) || v <= 0)) {
throw Object.assign(new Error(`${field}: a list of workspace ids`), { code: "EBADFIELD" });
}
return [...new Set(value)];
}
router.post(
"/",
accountChange(async (_req, body) => {
@@ -146,25 +171,78 @@ router.patch(
accountChange(async (req, body) => {
const u = target(req);
const self = u.id === req.user.id;
const done = [];
if (body.role !== undefined && body.role !== auth.publicUser(u).role) {
if (self)
throw Object.assign(new Error("you cannot change your own role"), { code: "ESELF" });
auth.setRole(u.username, body.role);
done.push(`role ${body.role}`);
const ESELF = (msg) => Object.assign(new Error(msg), { code: "ESELF" });
// 1. Validate everything — nothing is written until all of it passes.
const profile = auth.validateProfile(body);
const roleChange = body.role !== undefined && body.role !== u.role;
if (roleChange) {
if (self) throw ESELF("you cannot change your own role");
if (!auth.ROLES.includes(body.role)) {
throw Object.assign(new Error(`role: one of ${auth.ROLES.join(", ")}`), {
code: "EBADROLE",
});
}
}
if (body.disabled !== undefined) {
if (typeof body.disabled !== "boolean") {
throw Object.assign(new Error("disabled: true or false"), { code: "EBADFIELD" });
}
if (self && body.disabled) {
throw Object.assign(new Error("you cannot disable yourself"), { code: "ESELF" });
if (self && body.disabled) throw ESELF("you cannot disable yourself");
}
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
if (body.password !== undefined) auth.validatePassword(body.password);
if (body.signOut !== undefined && body.signOut !== true) {
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
}
const add = laneIds(body.addWorkspaces, "addWorkspaces");
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
const runAs = require("../lib/run-as");
for (const id of [...add, ...remove]) {
const lane = db.prepare("SELECT owner_id, cwd FROM lanes WHERE id = ?").get(id);
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
if (remove.includes(id) && lane.owner_id !== u.id) {
throw Object.assign(new Error(`workspace ${id} is not ${u.username}'s`), {
code: "ENOTOWNED",
});
}
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
if (add.includes(id)) runAs.assertOwnerFits(lane.cwd, u.id);
}
// 2. Apply. Disable last: it may stop panes and lock a Linux account
// (EPROVISION), and the admin should learn that after the rest held.
const done = [];
if (Object.keys(profile).length) {
auth.setProfile(u.id, profile);
if ("display_name" in profile) done.push("display name");
if ("email" in profile) done.push("email");
}
if (roleChange) {
auth.setRole(u.username, body.role);
done.push(`role ${body.role}`);
}
for (const id of add) auth.assignWorkspace(id, u.username);
for (const id of remove) auth.unassignWorkspace(id, u.username);
if (add.length) done.push(`workspace ${add.join(", ")}`);
if (remove.length) done.push(`removed workspace ${remove.join(", ")}`);
// An admin changing their own password or signing themselves out keeps
// the login they are doing it from.
const keepToken = self ? auth.cookieToken(req) : undefined;
if (body.password !== undefined) {
await auth.setPassword(u.username, body.password, { keepToken });
done.push("password reset");
}
if (body.signOut) {
auth.endSessions(u.id, keepToken);
done.push("signed out everywhere");
}
if (disabledChange) {
auth.setDisabled(u.username, body.disabled);
done.push(body.disabled ? "disable" : "enable");
}
return {
user: auth.publicUser(target(req)),
user: { ...auth.publicUser(target(req)), ...auth.loginStats(u.id) },
action: `${u.username}: ${done.join(", ") || "no change"}`,
};
})