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
This commit is contained in:
2026-10-07 11:18:10 +07:00
parent 7543b8d62a
commit 8fdda66893
24 changed files with 504 additions and 59 deletions
+60
View File
@@ -384,3 +384,63 @@ describe("cc-profiles", () => {
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" });
});
});
+106 -1
View File
@@ -3,7 +3,8 @@
* window mapped to the status bar's label/percent/reset, and a window that has
* reset since Codex last wrote reading as empty, and the Codex login's
* account + expiry read from `auth.json`; and a saved Claude profile asked
* with its own token only while it is valid, never refreshed.
* with its own token only while it is valid, never refreshed, and never shown
* with the usage of a token that belongs to a different account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -83,6 +84,10 @@ test("a saved profile is asked with its own token only while valid, never refres
const asked = [];
process.env.CLAUDE_HOME = home;
global.fetch = async (url, opts) => {
if (url.endsWith("/profile")) {
// The fresh profile's token is its own account's.
return { ok: true, json: async () => ({ account: { uuid: "fresh" } }) };
}
asked.push(`${url} ${opts.headers.Authorization}`);
return {
status: 200,
@@ -107,3 +112,103 @@ test("a saved profile is asked with its own token only while valid, never refres
fs.rmSync(home, { recursive: true, force: true });
}
});
test("a profile holding another account's token shows EWRONGACCOUNT, not that account's usage", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-owner-"));
const dir = path.join(home, "agenthub-profiles");
fs.mkdirSync(dir);
fs.writeFileSync(
path.join(dir, "victor.json"),
JSON.stringify({
label: "Victor",
account: { accountUuid: "victor", emailAddress: "victor@x.y" },
credentials: { accessToken: "tok", refreshToken: "r", expiresAt: Date.now() + 3600_000 },
})
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? { account: { uuid: "thang" } }
: { five_hour: { utilization: 30, resets_at: null } },
});
try {
__resetCache();
const [victor] = (await quotas()).tools.filter((t) => t.id === "claude");
assert.strictEqual(victor.label, "Victor");
assert.strictEqual(victor.error, "EWRONGACCOUNT");
assert.deepStrictEqual(victor.windows, []);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(home, { recursive: true, force: true });
}
});
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-live-"));
const home = path.join(root, ".claude");
const dir = path.join(home, "agenthub-profiles");
fs.mkdirSync(dir, { recursive: true });
const save = (name, label, uuid) =>
fs.writeFileSync(
path.join(dir, `${name}.json`),
JSON.stringify({
label,
account: { accountUuid: uuid, emailAddress: `${name}@x.y` },
credentials: { accessToken: `tok-${name}`, refreshToken: "r", expiresAt: 1 },
})
);
save("thang", "Thắng", "uuid-t");
save("victor", "Victor", "uuid-v");
// Thắng's identity, Victor's token.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
);
fs.writeFileSync(
`${home}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-t", emailAddress: "thang@x.y" } })
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? { account: { uuid: "uuid-v", email: "victor@x.y" } }
: { five_hour: { utilization: 82, resets_at: null } },
});
try {
__resetCache();
const claude = (await quotas()).tools.filter((t) => t.id === "claude");
const live = claude.find((t) => t.active);
assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined);
assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label),
["Thắng"]
);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
+27 -2
View File
@@ -1,13 +1,14 @@
/**
* @file Tests for server/lib/shell-path.js — login-shell PATH hydration with a
* stubbed spawner (noisy rc output, errors, opt-out, dedupe) plus one real
* `/bin/sh` run to prove the argv works end to end.
* `/bin/sh` run to prove the argv works end to end, and the UTF-8 locale
* default that keeps tmux from printing `_` for every non-ASCII character.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { hydrateShellPath, shellPath } = require("../lib/shell-path");
const { hydrateShellPath, shellPath, ensureUtf8Locale } = require("../lib/shell-path");
const M = "__AGENTHUB_PATH__";
const stub =
@@ -69,3 +70,27 @@ describe("hydrateShellPath", () => {
assert.ok(process.env.PATH.startsWith("/usr/bin:/bin"));
});
});
describe("ensureUtf8Locale", () => {
it("sets LC_CTYPE when no locale is set (a macOS LaunchAgent)", () => {
const env = {};
ensureUtf8Locale(env, "darwin");
assert.equal(env.LC_CTYPE, "en_US.UTF-8");
const linux = { LANG: "C" };
ensureUtf8Locale(linux, "linux");
assert.equal(linux.LC_CTYPE, "C.UTF-8");
});
it("leaves a UTF-8 locale, an explicit LC_ALL and Windows alone", () => {
for (const [env, platform] of [
[{ LANG: "vi_VN.UTF-8" }, "darwin"],
[{ LC_CTYPE: "en_US.utf8" }, "linux"],
[{ LC_ALL: "C" }, "darwin"],
[{}, "win32"],
]) {
const before = { ...env };
ensureUtf8Locale(env, platform);
assert.deepEqual(env, before);
}
});
});