7798530528
Codex >= 0.164 sends every request through a shared app-server daemon that outlives its TUIs and keeps the login it started with, so writing a new auth.json changed nothing: open sessions and even new ones stayed on the old (out-of-quota) account. After a switch, restart that CODEX_HOME's daemon when one runs; open TUIs reconnect to it on their own (verified on 0.164.0 with two keys). The response says so (following), and the toast tells the user running sessions follow.
285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
/**
|
|
* @file Tests for the non-Claude login profiles (tool-profiles.js): saving the
|
|
* live Codex login, switching between logins, the guards that keep a switch from
|
|
* losing the only copy of one, and Oh My Pi's credential identity.
|
|
* @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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tool-profiles-"));
|
|
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
|
process.env.CODEX_HOME = path.join(ROOT, ".codex");
|
|
|
|
const { describe, it, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const profiles = require("../lib/tool-profiles");
|
|
|
|
const AUTH = path.join(process.env.CODEX_HOME, "auth.json");
|
|
|
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
|
|
|
const jwt = (claims) => `x.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
|
|
|
|
/** Leave a ChatGPT login on disk the way `codex login` would. */
|
|
function login(account, email, refresh = "rt-1") {
|
|
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
|
fs.writeFileSync(
|
|
AUTH,
|
|
JSON.stringify({
|
|
OPENAI_API_KEY: null,
|
|
tokens: {
|
|
id_token: jwt({
|
|
email,
|
|
"https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_plan_type: "plus" },
|
|
}),
|
|
access_token: "at",
|
|
refresh_token: refresh,
|
|
account_id: account,
|
|
},
|
|
last_refresh: "2026-10-01T00:00:00Z",
|
|
})
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
|
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("tool-profiles: codex", () => {
|
|
it("reports the live login and saves it under a name derived from it", () => {
|
|
login("acct-a", "alice@example.com");
|
|
assert.deepEqual(profiles.listProfiles("codex").active, {
|
|
kind: "chatgpt",
|
|
email: "alice@example.com",
|
|
detail: "plus",
|
|
});
|
|
const r = profiles.saveProfile("codex");
|
|
assert.equal(r.name, "alice");
|
|
const { items } = profiles.listProfiles("codex");
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].active, true);
|
|
assert.equal(items[0].secret, undefined, "never a token in a listing");
|
|
const mode = fs.statSync(path.join(profiles.profilesDir("codex"), "alice.json")).mode & 0o777;
|
|
assert.equal(mode, 0o600);
|
|
});
|
|
|
|
it("re-saving refreshes the login's own profile; a second name is EDUPLICATE", () => {
|
|
login("acct-a", "alice@example.com");
|
|
profiles.saveProfile("codex");
|
|
login("acct-a", "alice@example.com", "rt-2");
|
|
assert.equal(profiles.saveProfile("codex").name, "alice");
|
|
assert.equal(profiles.readProfile("codex", "alice").secret.tokens.refresh_token, "rt-2");
|
|
assert.throws(() => profiles.saveProfile("codex", "other"), { code: "EDUPLICATE" });
|
|
});
|
|
|
|
it("switches, saving the outgoing login back first", () => {
|
|
login("acct-a", "alice@example.com");
|
|
profiles.saveProfile("codex");
|
|
login("acct-b", "bob@example.com");
|
|
profiles.saveProfile("codex");
|
|
// Alice's tokens rotated while she was live? Bob is live now; switch to Alice.
|
|
const r = profiles.activateProfile("codex", "alice");
|
|
assert.equal(r.savedOutgoing, "bob");
|
|
const live = JSON.parse(fs.readFileSync(AUTH, "utf8"));
|
|
assert.equal(live.tokens.account_id, "acct-a");
|
|
const { items } = profiles.listProfiles("codex");
|
|
assert.deepEqual(
|
|
items.map((p) => [p.name, p.active]),
|
|
[
|
|
["alice", true],
|
|
["bob", false],
|
|
]
|
|
);
|
|
});
|
|
|
|
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
|
|
login("acct-a", "alice@example.com");
|
|
profiles.saveProfile("codex");
|
|
login("acct-b", "bob@example.com");
|
|
profiles.saveProfile("codex");
|
|
assert.equal(profiles.activateProfile("codex", "alice").following, false);
|
|
|
|
const log = path.join(ROOT, "codex-argv.log");
|
|
const bin = path.join(ROOT, "fake-codex.sh");
|
|
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
|
|
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
|
|
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
|
fs.writeFileSync(sock, "");
|
|
process.env.AGENTHUB_CODEX_BIN = bin;
|
|
try {
|
|
assert.equal(profiles.activateProfile("codex", "bob").following, true);
|
|
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
assert.equal(
|
|
fs.readFileSync(log, "utf8").trim(),
|
|
`${process.env.CODEX_HOME} app-server daemon restart`
|
|
);
|
|
} finally {
|
|
delete process.env.AGENTHUB_CODEX_BIN;
|
|
}
|
|
});
|
|
|
|
it("refuses to switch away from an unsaved login", () => {
|
|
login("acct-a", "alice@example.com");
|
|
profiles.saveProfile("codex");
|
|
login("acct-c", "carol@example.com");
|
|
assert.throws(() => profiles.activateProfile("codex", "alice"), { code: "EUNSAVED" });
|
|
});
|
|
|
|
it("will not delete the live login's profile, deletes another", () => {
|
|
login("acct-a", "alice@example.com");
|
|
profiles.saveProfile("codex");
|
|
login("acct-b", "bob@example.com");
|
|
profiles.saveProfile("codex");
|
|
assert.throws(() => profiles.deleteProfile("codex", "bob"), { code: "EACTIVE" });
|
|
profiles.deleteProfile("codex", "alice");
|
|
assert.deepEqual(
|
|
profiles.listProfiles("codex").items.map((p) => p.name),
|
|
["bob"]
|
|
);
|
|
});
|
|
|
|
it("renames the label only, and refuses a taken one", () => {
|
|
login("acct-a", "alice@example.com");
|
|
profiles.saveProfile("codex");
|
|
login("acct-b", "bob@example.com");
|
|
profiles.saveProfile("codex");
|
|
profiles.renameProfile("codex", "alice", "Công việc");
|
|
assert.equal(
|
|
profiles.listProfiles("codex").items.find((p) => p.name === "alice").label,
|
|
"Công việc"
|
|
);
|
|
assert.throws(() => profiles.renameProfile("codex", "bob", "công việc"), {
|
|
code: "ELABELTAKEN",
|
|
});
|
|
});
|
|
|
|
it("an API-key login is identified by its key, shown by its last four", () => {
|
|
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
|
fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-test-abcdefghij1234" }));
|
|
const r = profiles.saveProfile("codex");
|
|
assert.equal(r.kind, "apikey");
|
|
assert.equal(r.detail, "…1234");
|
|
});
|
|
|
|
it("saving with no login is ENOAUTH", () => {
|
|
assert.throws(() => profiles.saveProfile("codex"), { code: "ENOAUTH" });
|
|
});
|
|
});
|
|
|
|
describe("tool-profiles: tools", () => {
|
|
it("lists antigravity as unsupported and refuses it", () => {
|
|
const agy = profiles.tools().find((t) => t.tool === "antigravity");
|
|
assert.equal(agy.supported, false);
|
|
assert.throws(() => profiles.listProfiles("antigravity"), { code: "EUNSUPPORTED" });
|
|
assert.throws(() => profiles.listProfiles("nope"), { code: "EBADTOOL" });
|
|
});
|
|
|
|
it("an omp credential is identified by who it is, never by its rotating token", () => {
|
|
const a = { provider: "anthropic", credential: { type: "oauth", access: "1", email: "a@x" } };
|
|
const b = { provider: "anthropic", credential: { type: "oauth", access: "2", email: "a@x" } };
|
|
assert.equal(profiles.ompCredentialId(a), profiles.ompCredentialId(b));
|
|
assert.equal(profiles.ompCredentialId(a), "anthropic:a@x");
|
|
});
|
|
|
|
it("a switch hands the adapter the outgoing login so it can restore it", () => {
|
|
let disk = { who: "a" };
|
|
const writes = [];
|
|
profiles.__setAdapter("fake", {
|
|
label: "Fake",
|
|
read: () => disk,
|
|
write: (secret, outgoing) => {
|
|
writes.push([secret.who, outgoing && outgoing.who]);
|
|
disk = secret;
|
|
return [];
|
|
},
|
|
identify: (s) => ({ key: s.who, kind: "x", email: null, detail: null }),
|
|
});
|
|
profiles.saveProfile("fake", "a");
|
|
disk = { who: "b" };
|
|
profiles.saveProfile("fake", "b");
|
|
profiles.activateProfile("fake", "a");
|
|
assert.deepEqual(writes, [["a", "b"]]);
|
|
});
|
|
});
|
|
|
|
describe("tool-profiles: omp", () => {
|
|
it("switches through a cleared store + `omp auth import -`, never by argv", () => {
|
|
const omp = path.join(ROOT, ".omp");
|
|
const db = path.join(omp, "agent", "agent.db");
|
|
const state = path.join(ROOT, "omp-state.json");
|
|
const argv = path.join(ROOT, "omp-argv.log");
|
|
fs.mkdirSync(path.dirname(db), { recursive: true });
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
const d = new DatabaseSync(db);
|
|
d.exec(`CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, data TEXT);
|
|
CREATE TABLE auth_credential_blocks (credential_id INTEGER);
|
|
CREATE TABLE auth_credential_refresh_leases (credential_id INTEGER);
|
|
INSERT INTO auth_credentials (provider, data) VALUES ('anthropic', '{}');`);
|
|
d.close();
|
|
// A fake omp: export prints the state file, import REPLACES it from stdin
|
|
// only when the store was cleared first (as the real one merges).
|
|
const bin = path.join(ROOT, "omp");
|
|
fs.writeFileSync(
|
|
bin,
|
|
`#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
fs.appendFileSync(${JSON.stringify(argv)}, process.argv.slice(2).join(" ") + "\\n");
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
const db = new DatabaseSync(${JSON.stringify(db)});
|
|
const rows = db.prepare("SELECT COUNT(*) AS n FROM auth_credentials").get().n;
|
|
if (process.argv[3] === "export") {
|
|
process.stdout.write(fs.readFileSync(${JSON.stringify(state)}, "utf8"));
|
|
} else {
|
|
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
if (rows) { process.stderr.write("merged into a non-empty store"); process.exit(1); }
|
|
fs.writeFileSync(${JSON.stringify(state)}, JSON.stringify(input));
|
|
}
|
|
`,
|
|
{ mode: 0o755 }
|
|
);
|
|
const set = (email, token) =>
|
|
fs.writeFileSync(
|
|
state,
|
|
JSON.stringify({
|
|
version: 1,
|
|
credentials: [
|
|
{ provider: "anthropic", credential: { type: "oauth", access: token, email } },
|
|
],
|
|
})
|
|
);
|
|
process.env.AGENTHUB_OMP_DIR = omp;
|
|
process.env.AGENTHUB_OMP_BIN = bin;
|
|
try {
|
|
set("a@x", "secret-a");
|
|
assert.equal(profiles.saveProfile("omp").name, "a");
|
|
set("b@x", "secret-b");
|
|
profiles.saveProfile("omp");
|
|
const r = profiles.activateProfile("omp", "a");
|
|
assert.equal(r.savedOutgoing, "b");
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(state, "utf8")).credentials[0].credential.email,
|
|
"a@x"
|
|
);
|
|
assert.ok(!fs.readFileSync(argv, "utf8").includes("secret"), "secrets only on stdin");
|
|
const { items, active } = profiles.listProfiles("omp");
|
|
assert.equal(active.email, "a@x");
|
|
assert.deepEqual(
|
|
items.map((p) => [p.name, p.active, p.detail]),
|
|
[
|
|
["a", true, "anthropic"],
|
|
["b", false, "anthropic"],
|
|
]
|
|
);
|
|
} finally {
|
|
delete process.env.AGENTHUB_OMP_DIR;
|
|
delete process.env.AGENTHUB_OMP_BIN;
|
|
}
|
|
});
|
|
});
|