7a88b74016
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
/**
|
|
* @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 a host pane's account when it is the host's own login, and nothing is written", () => {
|
|
const hostCreds = path.join(TMP, ".claude", ".credentials.json");
|
|
fs.mkdirSync(path.dirname(hostCreds), { recursive: true });
|
|
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("h") }));
|
|
try {
|
|
pool.addLoginAccount("mine", { oauth: oauth("h"), account: ACCOUNT, hostHolds: true });
|
|
const spawn = pool.forSpawn(null);
|
|
assert.equal(spawn.name, "mine", "the host pane's usage is booked to it");
|
|
assert.deepEqual(spawn.env, {});
|
|
spawn.place();
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(hostCreds, "utf8")).claudeAiOauth.refreshToken,
|
|
oauth("h").refreshToken
|
|
);
|
|
// The host logged in as someone else: no longer the pool's.
|
|
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("other") }));
|
|
assert.equal(pool.forSpawn(null), null);
|
|
} finally {
|
|
fs.rmSync(hostCreds, { force: true });
|
|
}
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|