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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user