mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
Adds the storage substrate for ccsx auth profile isolation. Each Codex profile gets its own CODEX_HOME dir under ~/.ccs/codex-instances/<name>/ with isolated auth.json and history.jsonl; config.toml is shared via symlink to ~/.codex/config.toml so two terminals can run two Codex accounts simultaneously without duplicating user config. - CodexProfileRegistry (YAML, atomic write tmp.<pid>.<rand> + rename, orphan cleanup, full CRUD + default pointer) - decode-id-token: pure base64 JWT decoder for OpenAI id_token, reads nested https://api.openai.com/auth claims (chatgpt_plan_type, chatgpt_account_id) and dual-path email - ensureSharedConfigSymlink: self-healing, idempotent, overwrites stale entries with stderr warning - 45 unit tests, all green Foundation only — no CLI, no runtime injection, no dashboard. Subsequent commits wire those in.
76 lines
2.7 KiB
TypeScript
76 lines
2.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
let decodeAccountIdentity: (authJsonPath: string) => {
|
|
email?: string;
|
|
plan_type?: string;
|
|
account_id?: string;
|
|
};
|
|
|
|
function buildToken(payload: Record<string, unknown>): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
return `${header}.${body}.fakesig`;
|
|
}
|
|
|
|
const VALID_TOKEN = buildToken({
|
|
email: 'test@example.com',
|
|
'https://api.openai.com/auth': {
|
|
chatgpt_plan_type: 'pro',
|
|
chatgpt_account_id: 'acct-abc123',
|
|
},
|
|
});
|
|
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-identity-test-'));
|
|
const mod = await import('../../../src/codex-auth/codex-account-identity');
|
|
decodeAccountIdentity = mod.decodeAccountIdentity;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('decodeAccountIdentity', () => {
|
|
it('returns {} when auth.json does not exist', () => {
|
|
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('returns identity fields from valid auth.json with id_token', () => {
|
|
const authJson = {
|
|
auth_mode: 'chatgpt_oauth',
|
|
tokens: { id_token: VALID_TOKEN, access_token: 'acc', refresh_token: 'ref' },
|
|
};
|
|
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
|
|
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
|
expect(result.email).toBe('test@example.com');
|
|
expect(result.plan_type).toBe('pro');
|
|
expect(result.account_id).toBe('acct-abc123');
|
|
});
|
|
|
|
it('returns {} when auth.json contains corrupt JSON', () => {
|
|
fs.writeFileSync(path.join(tempDir, 'auth.json'), '{ not valid json !!!', { mode: 0o600 });
|
|
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('returns {} when id_token field is missing from tokens', () => {
|
|
const authJson = { auth_mode: 'openai', tokens: { access_token: 'acc' } };
|
|
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
|
|
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('returns {} when tokens field is absent entirely', () => {
|
|
const authJson = { auth_mode: 'openai', OPENAI_API_KEY: 'sk-...' };
|
|
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
|
|
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
|
expect(result).toEqual({});
|
|
});
|
|
});
|