Files
AgentHub/server/__tests__/cc-profiles.test.js
T
nntrivi2001 4bc77ded17 feat(cc-config): switch between Claude Code accounts, keeping the workspace
Adds a "Switch profile" button left of Backups on /settings?tool=claude-code.
A *profile* is one Anthropic account - one quota - saved under a name, so the
local `claude` CLI can be pointed at a different account without disturbing
anything else.

A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth`
inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside
the `.claude.json` config file(s). Those files are key-merged in place, and only
where they already exist, so `projects`/`numStartups`/everything else survives.
Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and
settings are never read or written here - that is what makes a switch keep the
whole workspace and change only the account. `mcpOAuth` stays put too: those are
the machine's MCP server tokens, not account identity.

Profiles are one JSON file each under `<CLAUDE_HOME>/agenthub-profiles/` (dir
0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route
ever returns token material - `GET /profiles` answers identity metadata only.

The dashboard cannot log an account in; only `claude auth login` mints tokens.
Adding a profile is therefore always "log in from a terminal, then snapshot what
the CLI wrote". `activateProfile` re-snapshots the outgoing account first so
tokens Claude Code refreshed since its last save are not lost, and refuses
(`EUNSAVED`) to switch away from an account that was never saved - that would
discard the only copy of its credentials. A switch applies to new sessions only,
since a running session rewrites the config on exit.

Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate.

Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700
permissions), client 459/459.
2026-09-07 15:13:38 +07:00

215 lines
7.8 KiB
JavaScript

/**
* @file Tests for named Claude Code auth profiles — snapshotting the logged-in
* account, switching between accounts, and the guards that keep a switch from
* destroying the only copy of a credential.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cc-profiles-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const profiles = require("../lib/cc-profiles");
const HOME = process.env.CLAUDE_HOME;
const CREDS = path.join(HOME, ".credentials.json");
const CONFIG = `${HOME}.json`;
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
/** Put one account on disk the way the `claude` CLI would leave it. */
function login(uuid, email, { mcp = true, extras = {} } = {}) {
fs.mkdirSync(HOME, { recursive: true });
fs.writeFileSync(
CREDS,
JSON.stringify({
...(mcp ? { mcpOAuth: { "some:server": { accessToken: "mcp-token" } } } : {}),
claudeAiOauth: { accessToken: `at-${uuid}`, refreshToken: `rt-${uuid}`, expiresAt: 111 },
})
);
fs.writeFileSync(
CONFIG,
JSON.stringify({
numStartups: 7,
projects: { "/tmp/x": { allowedTools: [] } },
userID: `uid-${uuid}`,
oauthAccount: { accountUuid: uuid, emailAddress: email, organizationName: "Org" },
...extras,
})
);
}
beforeEach(() => {
fs.rmSync(HOME, { recursive: true, force: true });
fs.rmSync(CONFIG, { force: true });
});
describe("cc-profiles", () => {
it("reports the live account and no profiles before anything is saved", () => {
login("uuid-a", "a@example.com");
const { items, active } = profiles.listProfiles();
assert.deepEqual(items, []);
assert.equal(active.email, "a@example.com");
assert.equal(active.accountUuid, "uuid-a");
});
it("saves the current account and marks it active in the list", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const { items } = profiles.listProfiles();
assert.equal(items.length, 1);
assert.equal(items[0].name, "work");
assert.equal(items[0].email, "a@example.com");
assert.equal(items[0].active, true);
});
it("never exposes token material in the list", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const json = JSON.stringify(profiles.listProfiles());
assert.ok(!json.includes("at-uuid-a"), "access token leaked into the list response");
assert.ok(!json.includes("rt-uuid-a"), "refresh token leaked into the list response");
});
it("stores profiles 0600 in a 0700 dir", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const dir = profiles.profilesDir();
assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
assert.equal(fs.statSync(path.join(dir, "work.json")).mode & 0o777, 0o600);
});
it("rejects names that are not filename-safe", () => {
login("uuid-a", "a@example.com");
for (const bad of ["../escape", "has/slash", "", "a".repeat(41), ".dotfirst"]) {
assert.throws(() => profiles.saveProfile(bad), { code: "EBADNAME" }, `accepted ${bad}`);
}
});
it("refuses to save when nobody is logged in", () => {
fs.mkdirSync(HOME, { recursive: true });
assert.throws(() => profiles.saveProfile("work"), { code: "ENOAUTH" });
});
it("switches the account and keeps everything else in the config", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
const result = profiles.activateProfile("work");
assert.equal(result.savedOutgoing, "personal");
const creds = JSON.parse(fs.readFileSync(CREDS, "utf8"));
assert.equal(creds.claudeAiOauth.accessToken, "at-uuid-a");
assert.equal(creds.claudeAiOauth.refreshToken, "rt-uuid-a");
// MCP server tokens belong to the machine, not the account.
assert.equal(creds.mcpOAuth["some:server"].accessToken, "mcp-token");
const cfg = JSON.parse(fs.readFileSync(CONFIG, "utf8"));
assert.equal(cfg.oauthAccount.accountUuid, "uuid-a");
assert.equal(cfg.userID, "uid-uuid-a");
// Untouched: everything that is not the account identity.
assert.equal(cfg.numStartups, 7);
assert.deepEqual(cfg.projects, { "/tmp/x": { allowedTools: [] } });
const { items } = profiles.listProfiles();
assert.deepEqual(
items.map((p) => [p.name, p.active]),
[
["personal", false],
["work", true],
]
);
});
it("re-snapshots the outgoing account so refreshed tokens survive a round trip", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.activateProfile("work");
// Simulate Claude Code refreshing the now-active account's token.
const creds = JSON.parse(fs.readFileSync(CREDS, "utf8"));
creds.claudeAiOauth.accessToken = "at-refreshed";
fs.writeFileSync(CREDS, JSON.stringify(creds));
profiles.activateProfile("personal");
profiles.activateProfile("work");
assert.equal(
JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken,
"at-refreshed"
);
});
it("refuses to switch away from an account that was never saved", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com"); // logged in, but not snapshotted
assert.throws(() => profiles.activateProfile("work"), { code: "EUNSAVED" });
// Nothing moved.
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-b");
});
it("rewrites a second stale .claude.json so it cannot contradict the switch", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
const inner = path.join(HOME, ".claude.json");
fs.writeFileSync(
inner,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-stale" }, firstStartTime: "x" })
);
profiles.activateProfile("work");
const cfg = JSON.parse(fs.readFileSync(inner, "utf8"));
assert.equal(cfg.oauthAccount.accountUuid, "uuid-a");
assert.equal(cfg.firstStartTime, "x");
});
it("does not create a .claude.json that was not there", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.activateProfile("work");
assert.equal(fs.existsSync(path.join(HOME, ".claude.json")), false);
});
it("deletes an inactive profile and refuses the active one", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
assert.throws(() => profiles.deleteProfile("personal"), { code: "EACTIVE" });
assert.deepEqual(profiles.deleteProfile("work"), { ok: true, name: "work" });
assert.throws(() => profiles.deleteProfile("work"), { code: "ENOTFOUND" });
});
it("skips corrupt profile files instead of failing the whole list", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
fs.writeFileSync(path.join(profiles.profilesDir(), "broken.json"), "{not json");
const { items } = profiles.listProfiles();
assert.deepEqual(
items.map((p) => p.name),
["work"]
);
});
it("reports a corrupt live credentials file rather than guessing", () => {
fs.mkdirSync(HOME, { recursive: true });
fs.writeFileSync(CREDS, "{nope");
assert.throws(() => profiles.listProfiles(), { code: "EBADJSON" });
});
});