Files
nntrivi2001 8fdda66893 fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_`
for every non-ASCII character, and a macOS LaunchAgent starts the dashboard
with no locale at all — Vietnamese text and Claude Code's logo came out as
underscores in every browser terminal.

- shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS,
  C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and
  Windows are left alone
- RunSetup.tsx: the `/` list now offers Claude Code's bundled skills
  (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and
  every enabled plugin's commands and skills as /<plugin>:<name>
- Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached
  per token), not by .claude.json: a running session can leave the
  credentials store holding one account's token under another account's
  name. The Profiles modal, its Check quota and the status-bar quota now show
  the account requests are really billed to and warn on the mismatch; a
  switch saves the outgoing token into its OWNER's profile (credentials only)
  instead of overwriting the profile .claude.json names; saving a profile
  while they disagree is refused (EMISMATCH); a saved profile holding another
  account's token reports EWRONGACCOUNT instead of that account's usage
- Running Claude Code sessions follow a switch on their own (they re-read the
  credentials store when it changes), so the "close your sessions first"
  warning is gone
2026-10-07 11:18:10 +07:00

447 lines
18 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");
// File-backed by default, so running this suite on a Mac never reads or rewrites
// the developer's real login Keychain. The Keychain path has its own test below.
process.env.AGENTHUB_CC_KEYCHAIN = "0";
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("refuses to save an account that is already saved under another name", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.throws(() => profiles.saveProfile("work-again"), { code: "EDUPLICATE" });
// The rejected name left no file behind.
assert.deepEqual(
profiles.listProfiles().items.map((p) => p.name),
["work"]
);
});
it("names the profile that already holds the account, so the error is actionable", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.throws(() => profiles.saveProfile("dup"), /work/);
});
it("still re-snapshots an account into its own existing profile", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.equal(profiles.saveProfile("work").name, "work");
});
it("derives a filename-safe slug and a readable label when no name is given", () => {
login("uuid-a", "trí.vĩ@example.com");
const saved = profiles.saveProfile();
assert.match(saved.name, /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
const item = profiles.listProfiles().items[0];
assert.equal(item.name, saved.name);
assert.ok(item.label, "a saved profile always has a label to show");
});
it("labels an existing profile by its name when the file predates labels", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const file = path.join(profiles.profilesDir(), "work.json");
const stored = JSON.parse(fs.readFileSync(file, "utf8"));
delete stored.label;
fs.writeFileSync(file, JSON.stringify(stored));
assert.equal(profiles.listProfiles().items[0].label, "work");
});
it("renames a profile's label, keeping its credentials and its identifier", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const result = profiles.renameProfile("work", "Tài khoản chính");
assert.equal(result.name, "work");
assert.equal(result.label, "Tài khoản chính");
const item = profiles.listProfiles().items[0];
assert.equal(item.name, "work", "the identifier must not change under a rename");
assert.equal(item.label, "Tài khoản chính");
assert.equal(item.email, "a@example.com");
// Switching still works, so the credentials survived the rewrite.
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.activateProfile("work");
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-a");
});
it("accepts spaces and diacritics in a label but not control characters", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
for (const good of ["Tài khoản chính", "Work / Personal", "công ty ⭐", "a b"]) {
assert.equal(profiles.renameProfile("work", good).label, good);
}
for (const bad of ["", " ", "a\nb", "a\tb", "x".repeat(61), 5, null]) {
assert.throws(
() => profiles.renameProfile("work", bad),
{ code: "EBADLABEL" },
`took ${bad}`
);
}
});
it("trims a label rather than storing the padding", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
assert.equal(profiles.renameProfile("work", " Chính ").label, "Chính");
});
it("refuses a label another profile already uses, case-insensitively", () => {
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
profiles.saveProfile("personal");
profiles.renameProfile("work", "Chính");
assert.throws(() => profiles.renameProfile("personal", "chính"), { code: "ELABELTAKEN" });
// Renaming a profile to the label it already has is not a conflict.
assert.equal(profiles.renameProfile("work", "Chính").label, "Chính");
});
it("refuses to rename a profile that does not exist", () => {
login("uuid-a", "a@example.com");
assert.throws(() => profiles.renameProfile("nope", "X"), { code: "ENOTFOUND" });
});
it("deletes a duplicate of the live account while another copy still holds it", () => {
// The state a pre-EDUPLICATE save could leave behind: two profiles, one
// account, both flagged active — and with a blanket active-guard, neither
// deletable. Cleanup has to be possible.
login("uuid-a", "a@example.com");
profiles.saveProfile("work");
const dup = path.join(profiles.profilesDir(), "work-copy.json");
fs.copyFileSync(path.join(profiles.profilesDir(), "work.json"), dup);
assert.deepEqual(
profiles.listProfiles().items.map((p) => [p.name, p.active]),
[
["work", true],
["work-copy", true],
]
);
assert.deepEqual(profiles.deleteProfile("work-copy"), { ok: true, name: "work-copy" });
// The last copy of the live account is still protected.
assert.throws(() => profiles.deleteProfile("work"), { code: "EACTIVE" });
});
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("switches the account in the macOS Keychain, where the CLI actually reads it", () => {
const cp = require("node:child_process");
const realExec = cp.execFileSync;
const realPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
process.env.AGENTHUB_CC_KEYCHAIN = "1";
// A fake `security` binary backed by one string — the keychain item.
let item = null;
cp.execFileSync = (_bin, args) => {
if (args[0] === "find-generic-password") {
if (item === null) throw new Error("SecKeychainSearchCopyNext: not found");
return item;
}
if (args[0] === "add-generic-password") {
item = args[args.indexOf("-w") + 1];
return "";
}
throw new Error(`unexpected security verb ${args[0]}`);
};
try {
// Logged in as A, Keychain-only: no `.credentials.json` exists at all.
login("uuid-a", "a@example.com");
fs.rmSync(CREDS, { force: true });
item = JSON.stringify({
claudeAiOauth: { accessToken: "at-uuid-a", refreshToken: "rt-uuid-a" },
});
profiles.saveProfile("work");
login("uuid-b", "b@example.com");
fs.rmSync(CREDS, { force: true });
item = JSON.stringify({
claudeAiOauth: { accessToken: "at-uuid-b", refreshToken: "rt-uuid-b" },
});
profiles.saveProfile("personal");
assert.equal(profiles.listProfiles().items.find((p) => p.active).name, "personal");
const result = profiles.activateProfile("work");
assert.equal(JSON.parse(item).claudeAiOauth.accessToken, "at-uuid-a");
assert.ok(
result.written.some((w) => w.includes("Keychain")),
"the switch must report the Keychain as written"
);
assert.equal(fs.existsSync(CREDS), false, "no .credentials.json is invented on macOS");
} finally {
cp.execFileSync = realExec;
Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
process.env.AGENTHUB_CC_KEYCHAIN = "0";
}
});
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" });
});
});
// The state a running session can leave behind: `.claude.json` still names one
// account while the credentials store holds ANOTHER account's token. The token
// is what Claude Code actually uses, so its owner (asked of Anthropic by the
// route, passed in here) decides who is logged in.
describe("cc-profiles — identity and token disagree", () => {
/** Thắng and Victor saved; then Victor's token lands under Thắng's identity. */
function mixedLogin() {
login("uuid-t", "t@example.com");
profiles.saveProfile("thang");
login("uuid-v", "v@example.com");
profiles.saveProfile("victor");
login("uuid-t", "t@example.com");
fs.writeFileSync(
CREDS,
JSON.stringify({
claudeAiOauth: { accessToken: "at-v-new", refreshToken: "rt-v-new", expiresAt: 222 },
})
);
return { uuid: "uuid-v", email: "v@example.com" };
}
const stored = (name) => profiles.readProfile(name);
it("marks the token's owner active and reports the disagreement", () => {
const owner = mixedLogin();
const { items, active, mismatch } = profiles.listProfiles({ owner });
assert.deepEqual(
items.filter((p) => p.active).map((p) => p.name),
["victor"]
);
assert.equal(active.email, "v@example.com");
assert.equal(mismatch.configEmail, "t@example.com");
// Without an owner (offline) the config is all there is, as before.
assert.equal(profiles.listProfiles().mismatch, null);
});
it("a switch saves the outgoing token into its OWNER's profile, never the one config names", () => {
const owner = mixedLogin();
profiles.activateProfile("thang", { owner });
assert.equal(stored("victor").credentials.refreshToken, "rt-v-new");
assert.equal(stored("victor").account.emailAddress, "v@example.com");
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
// And the switch itself landed: Thắng's token and identity, together.
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-t");
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-t");
});
it("switching to the owner's own profile keeps the live (newest) token and fixes the identity", () => {
const owner = mixedLogin();
profiles.activateProfile("victor", { owner });
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.refreshToken, "rt-v-new");
assert.equal(JSON.parse(fs.readFileSync(CONFIG, "utf8")).oauthAccount.accountUuid, "uuid-v");
assert.equal(stored("thang").credentials.refreshToken, "rt-uuid-t");
});
it("refuses to save a profile while they disagree", () => {
const owner = mixedLogin();
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
});
});