4bc77ded17
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.
215 lines
7.8 KiB
JavaScript
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" });
|
|
});
|
|
});
|