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