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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user