From 358b703d984cb16f0f7473a2a7bd171f2aa0e43e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 17 May 2026 14:44:07 -0400 Subject: [PATCH] feat(codex-auth): add profile registry + storage foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the storage substrate for ccsx auth profile isolation. Each Codex profile gets its own CODEX_HOME dir under ~/.ccs/codex-instances// 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.. + 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. --- src/codex-auth/codex-account-identity.ts | 36 +++ src/codex-auth/codex-config-symlink.ts | 73 ++++++ src/codex-auth/codex-profile-paths.ts | 21 ++ src/codex-auth/codex-profile-registry.ts | 187 +++++++++++++++ src/codex-auth/decode-id-token.ts | 71 ++++++ src/codex-auth/index.ts | 12 + src/codex-auth/types.ts | 22 ++ .../codex-auth/codex-account-identity.test.ts | 75 ++++++ .../codex-auth/codex-config-symlink.test.ts | 107 +++++++++ .../codex-auth/codex-profile-paths.test.ts | 64 ++++++ .../codex-auth/codex-profile-registry.test.ts | 214 ++++++++++++++++++ tests/unit/codex-auth/decode-id-token.test.ts | 124 ++++++++++ 12 files changed, 1006 insertions(+) create mode 100644 src/codex-auth/codex-account-identity.ts create mode 100644 src/codex-auth/codex-config-symlink.ts create mode 100644 src/codex-auth/codex-profile-paths.ts create mode 100644 src/codex-auth/codex-profile-registry.ts create mode 100644 src/codex-auth/decode-id-token.ts create mode 100644 src/codex-auth/index.ts create mode 100644 src/codex-auth/types.ts create mode 100644 tests/unit/codex-auth/codex-account-identity.test.ts create mode 100644 tests/unit/codex-auth/codex-config-symlink.test.ts create mode 100644 tests/unit/codex-auth/codex-profile-paths.test.ts create mode 100644 tests/unit/codex-auth/codex-profile-registry.test.ts create mode 100644 tests/unit/codex-auth/decode-id-token.test.ts diff --git a/src/codex-auth/codex-account-identity.ts b/src/codex-auth/codex-account-identity.ts new file mode 100644 index 00000000..56b911ed --- /dev/null +++ b/src/codex-auth/codex-account-identity.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import { createLogger } from '../services/logging'; +import { decodeIdToken } from './decode-id-token'; +import type { CodexAccountIdentity } from './types'; + +const logger = createLogger('codex-auth:identity'); + +interface AuthJson { + tokens?: { + id_token?: string; + }; +} + +/** + * Read auth.json from disk and extract display-safe identity fields. + * Returns {} on any error (missing file, bad JSON, missing token, decode failure). + * Never throws. + */ +export function decodeAccountIdentity(authJsonPath: string): CodexAccountIdentity { + try { + const raw = fs.readFileSync(authJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as AuthJson; + const idToken = parsed?.tokens?.id_token; + if (typeof idToken !== 'string' || idToken.length === 0) { + return {}; + } + return decodeIdToken(idToken); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn( + 'codex-auth.identity.decode-failed', + `Failed to decode account identity from ${authJsonPath}: ${msg}` + ); + return {}; + } +} diff --git a/src/codex-auth/codex-config-symlink.ts b/src/codex-auth/codex-config-symlink.ts new file mode 100644 index 00000000..acddeb38 --- /dev/null +++ b/src/codex-auth/codex-config-symlink.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createLogger } from '../services/logging'; +import { getSharedCodexConfigPath } from './codex-profile-paths'; + +const logger = createLogger('codex-auth:symlink'); + +/** + * Ensure /config.toml is a symlink pointing to the shared + * ~/.codex/config.toml. Self-healing: recreates stale or missing symlinks. + * + * @param profileDir - The per-profile directory (will be created if missing). + * @param sharedConfigPath - Override for the shared target path (used in tests + * to avoid touching real ~/.codex/config.toml). Defaults to getSharedCodexConfigPath(). + */ +export function ensureSharedConfigSymlink(profileDir: string, sharedConfigPath?: string): void { + const targetPath = sharedConfigPath ?? getSharedCodexConfigPath(); + const linkPath = path.join(profileDir, 'config.toml'); + + // Ensure profile directory exists + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + + // Ensure shared config target parent directory exists + fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 }); + + // Create empty shared config if it doesn't exist — Codex will populate on first run + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, '', { mode: 0o600 }); + logger.stage('dispatch', 'codex.shared-config.created', 'Created empty shared config.toml', { + path: targetPath, + }); + } + + // Inspect whatever currently exists at the link path + let existingStat: fs.Stats | null = null; + try { + existingStat = fs.lstatSync(linkPath); + } catch { + // ENOENT — nothing there yet, proceed to create + } + + if (existingStat !== null) { + if (existingStat.isSymbolicLink()) { + const currentTarget = fs.readlinkSync(linkPath); + if (currentTarget === targetPath) { + // Already correct — idempotent return + logger.stage('dispatch', 'codex.symlink.ok', 'Shared config symlink already correct', { + link: linkPath, + }); + return; + } + // Stale symlink — remove and re-create + fs.unlinkSync(linkPath); + logger.stage('dispatch', 'codex.symlink.repaired', 'Replaced stale symlink', { + link: linkPath, + was: currentTarget, + now: targetPath, + }); + } else { + // Regular file or other non-symlink entry — overwrite with warning + process.stderr.write( + `[!] codex-auth: overwriting regular file at ${linkPath} with symlink to shared config.toml\n` + ); + fs.unlinkSync(linkPath); + } + } + + fs.symlinkSync(targetPath, linkPath); + logger.stage('dispatch', 'codex.symlink.created', 'Created shared config symlink', { + link: linkPath, + target: targetPath, + }); +} diff --git a/src/codex-auth/codex-profile-paths.ts b/src/codex-auth/codex-profile-paths.ts new file mode 100644 index 00000000..98deb6eb --- /dev/null +++ b/src/codex-auth/codex-profile-paths.ts @@ -0,0 +1,21 @@ +import * as path from 'path'; +import * as os from 'os'; +import { getCcsDir } from '../utils/config-manager'; + +export function getCodexAuthRegistryPath(): string { + return path.join(getCcsDir(), 'codex-profiles.yaml'); +} + +export function getCodexInstancesDir(): string { + return path.join(getCcsDir(), 'codex-instances'); +} + +export function resolveCodexProfileDir(name: string): string { + return path.join(getCodexInstancesDir(), name); +} + +// Uses os.homedir() intentionally — this is the upstream Codex location, +// not a CCS-owned path. Tests must override the shared config path explicitly. +export function getSharedCodexConfigPath(): string { + return path.join(os.homedir(), '.codex', 'config.toml'); +} diff --git a/src/codex-auth/codex-profile-registry.ts b/src/codex-auth/codex-profile-registry.ts new file mode 100644 index 00000000..1d0a75e9 --- /dev/null +++ b/src/codex-auth/codex-profile-registry.ts @@ -0,0 +1,187 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { createLogger } from '../services/logging'; +import { getCodexAuthRegistryPath } from './codex-profile-paths'; +import { CODEX_PROFILE_SCHEMA_VERSION } from './types'; +import type { CodexProfileData, CodexProfileMetadata } from './types'; + +const logger = createLogger('codex-auth:registry'); + +function emptyRegistry(): CodexProfileData { + return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} }; +} + +/** + * Registry for codex auth profiles stored at ~/.ccs/codex-profiles.yaml. + * + * All writes are atomic (tmp file + POSIX rename). Concurrent writers are + * safe: last-writer-wins for the default pointer; profile entries never + * partially corrupt because rename(2) is atomic. + * + * Constructor accepts an optional registryPath for test isolation. + */ +export class CodexProfileRegistry { + private readonly registryPath: string; + + constructor(registryPath?: string) { + this.registryPath = registryPath ?? getCodexAuthRegistryPath(); + this._cleanOrphanTmpFiles(); + } + + // ── private read/write ────────────────────────────────────────────────── + + private _read(): CodexProfileData { + if (!fs.existsSync(this.registryPath)) { + return emptyRegistry(); + } + try { + const raw = fs.readFileSync(this.registryPath, 'utf8'); + const parsed = yaml.load(raw) as CodexProfileData | null; + if (!parsed || typeof parsed !== 'object' || !parsed.profiles) { + return emptyRegistry(); + } + return parsed; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn( + 'codex-auth.registry.corrupt', + `Corrupt registry at ${this.registryPath}, returning empty state: ${msg}` + ); + return emptyRegistry(); + } + } + + private _write(data: CodexProfileData): void { + const dir = path.dirname(this.registryPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Unique tmp suffix avoids collisions on concurrent writes and orphan leaks + const tmpPath = `${this.registryPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`; + + try { + fs.writeFileSync(tmpPath, yaml.dump(data, { indent: 2, lineWidth: -1 }), { + mode: 0o600, + }); + fs.renameSync(tmpPath, this.registryPath); + } catch (err) { + if (fs.existsSync(tmpPath)) { + try { + fs.unlinkSync(tmpPath); + } catch { + // best-effort cleanup + } + } + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to write codex profile registry: ${msg}`); + } + } + + // Best-effort cleanup of orphan tmp files older than 1 hour (H3 mitigation). + private _cleanOrphanTmpFiles(): void { + const dir = path.dirname(this.registryPath); + const base = path.basename(this.registryPath); + if (!fs.existsSync(dir)) return; + try { + const oneHourAgo = Date.now() - 60 * 60 * 1000; + for (const entry of fs.readdirSync(dir)) { + if (!entry.startsWith(`${base}.tmp.`)) continue; + const full = path.join(dir, entry); + try { + const stat = fs.statSync(full); + if (stat.mtimeMs < oneHourAgo) { + fs.unlinkSync(full); + } + } catch { + // ignore per-file errors + } + } + } catch { + // ignore cleanup failure silently + } + } + + // ── CRUD ──────────────────────────────────────────────────────────────── + + createProfile(name: string, meta: Partial = {}): void { + const data = this._read(); + if (data.profiles[name]) { + throw new Error(`Profile already exists: ${name}`); + } + data.profiles[name] = { + type: 'codex', + created: new Date().toISOString(), + last_used: null, + ...meta, + } as CodexProfileMetadata; + this._write(data); + logger.stage('route', 'codex-auth.profile.created', 'Codex profile created', { name }); + } + + getProfile(name: string): CodexProfileMetadata { + const data = this._read(); + const profile = data.profiles[name]; + if (!profile) { + throw new Error(`Profile not found: ${name}`); + } + return profile; + } + + updateProfile(name: string, partial: Partial): void { + const data = this._read(); + if (!data.profiles[name]) { + throw new Error(`Profile not found: ${name}`); + } + data.profiles[name] = { ...data.profiles[name], ...partial } as CodexProfileMetadata; + this._write(data); + } + + removeProfile(name: string): void { + const data = this._read(); + if (!data.profiles[name]) { + throw new Error(`Profile not found: ${name}`); + } + delete data.profiles[name]; + if (data.default === name) { + const remaining = Object.keys(data.profiles); + data.default = remaining.length > 0 ? remaining[0] : null; + } + this._write(data); + logger.stage('cleanup', 'codex-auth.profile.deleted', 'Codex profile removed', { name }); + } + + listProfiles(): string[] { + return Object.keys(this._read().profiles); + } + + hasProfile(name: string): boolean { + return Object.prototype.hasOwnProperty.call(this._read().profiles, name); + } + + // ── Default pointer ────────────────────────────────────────────────────── + + getDefault(): string | null { + return this._read().default; + } + + setDefault(name: string): void { + const data = this._read(); + if (!data.profiles[name]) { + throw new Error(`Profile not found: ${name}`); + } + data.default = name; + this._write(data); + } + + clearDefault(): void { + const data = this._read(); + data.default = null; + this._write(data); + } + + touchProfile(name: string): void { + this.updateProfile(name, { last_used: new Date().toISOString() }); + } +} diff --git a/src/codex-auth/decode-id-token.ts b/src/codex-auth/decode-id-token.ts new file mode 100644 index 00000000..ad55bb40 --- /dev/null +++ b/src/codex-auth/decode-id-token.ts @@ -0,0 +1,71 @@ +import type { CodexAccountIdentity } from './types'; + +// JWT claim URI for OpenAI-specific auth data (nested object). +// Verified against real auth.json: chatgpt_plan_type and chatgpt_account_id +// live under this key, NOT at top level. +const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth'; +const OPENAI_PROFILE_CLAIM = 'https://api.openai.com/profile'; + +interface OpenAIAuthClaim { + chatgpt_plan_type?: string; + chatgpt_account_id?: string; +} + +interface OpenAIProfileClaim { + email?: string; +} + +interface JwtPayload { + email?: string; + [OPENAI_AUTH_CLAIM]?: OpenAIAuthClaim; + [OPENAI_PROFILE_CLAIM]?: OpenAIProfileClaim; + [key: string]: unknown; +} + +function base64urlDecode(str: string): string { + // Convert base64url to standard base64 + const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + return Buffer.from(padded, 'base64').toString('utf8'); +} + +/** + * Decode the payload of a JWT id_token without signature verification. + * Returns only the display-safe fields: email, plan_type, account_id. + * Returns {} on any parse failure — never throws. + * + * Security note: signature is NOT verified. This is purely cosmetic data + * for dashboard display. Auth boundary is OS file perms on auth.json. + */ +export function decodeIdToken(idToken: string): CodexAccountIdentity { + try { + const parts = idToken.split('.'); + if (parts.length < 3) { + return {}; + } + + const rawPayload = base64urlDecode(parts[1]); + const payload = JSON.parse(rawPayload) as JwtPayload; + + const authClaim = payload[OPENAI_AUTH_CLAIM]; + const profileClaim = payload[OPENAI_PROFILE_CLAIM]; + + // Email: prefer top-level, fall back to profile claim + const email = payload.email ?? profileClaim?.email; + + const result: CodexAccountIdentity = {}; + if (typeof email === 'string' && email.length > 0) { + result.email = email; + } + if (typeof authClaim?.chatgpt_plan_type === 'string') { + result.plan_type = authClaim.chatgpt_plan_type; + } + if (typeof authClaim?.chatgpt_account_id === 'string') { + result.account_id = authClaim.chatgpt_account_id; + } + + return result; + } catch { + return {}; + } +} diff --git a/src/codex-auth/index.ts b/src/codex-auth/index.ts new file mode 100644 index 00000000..7666e4b9 --- /dev/null +++ b/src/codex-auth/index.ts @@ -0,0 +1,12 @@ +export { CodexProfileRegistry } from './codex-profile-registry'; +export { + getCodexAuthRegistryPath, + getCodexInstancesDir, + resolveCodexProfileDir, + getSharedCodexConfigPath, +} from './codex-profile-paths'; +export { ensureSharedConfigSymlink } from './codex-config-symlink'; +export { decodeAccountIdentity } from './codex-account-identity'; +export { decodeIdToken } from './decode-id-token'; +export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types'; +export { CODEX_PROFILE_SCHEMA_VERSION } from './types'; diff --git a/src/codex-auth/types.ts b/src/codex-auth/types.ts new file mode 100644 index 00000000..2280f553 --- /dev/null +++ b/src/codex-auth/types.ts @@ -0,0 +1,22 @@ +export interface CodexProfileMetadata { + type: 'codex'; + created: string; + last_used: string | null; + email?: string; + plan_type?: string | null; + account_id?: string; +} + +export interface CodexProfileData { + version: string; + default: string | null; + profiles: Record; +} + +export interface CodexAccountIdentity { + email?: string; + plan_type?: string; + account_id?: string; +} + +export const CODEX_PROFILE_SCHEMA_VERSION = '1.0'; diff --git a/tests/unit/codex-auth/codex-account-identity.test.ts b/tests/unit/codex-auth/codex-account-identity.test.ts new file mode 100644 index 00000000..7a015730 --- /dev/null +++ b/tests/unit/codex-auth/codex-account-identity.test.ts @@ -0,0 +1,75 @@ +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 { + 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({}); + }); +}); diff --git a/tests/unit/codex-auth/codex-config-symlink.test.ts b/tests/unit/codex-auth/codex-config-symlink.test.ts new file mode 100644 index 00000000..f03e5ead --- /dev/null +++ b/tests/unit/codex-auth/codex-config-symlink.test.ts @@ -0,0 +1,107 @@ +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 ensureSharedConfigSymlink: (profileDir: string, sharedConfigPath?: string) => void; + +let tempDir: string; +let profileDir: string; +let sharedConfigPath: string; + +beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-symlink-test-')); + profileDir = path.join(tempDir, 'profile'); + // Use a temp path for the shared config so tests never touch real ~/.codex/config.toml + sharedConfigPath = path.join(tempDir, 'shared-config.toml'); + const mod = await import('../../../src/codex-auth/codex-config-symlink'); + ensureSharedConfigSymlink = mod.ensureSharedConfigSymlink; +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('ensureSharedConfigSymlink', () => { + it('creates empty shared config and symlink when neither exists', () => { + // Neither profileDir nor sharedConfigPath exist yet + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + + expect(fs.existsSync(sharedConfigPath)).toBe(true); + expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe(''); + + const linkPath = path.join(profileDir, 'config.toml'); + const stat = fs.lstatSync(linkPath); + expect(stat.isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); + }); + + it('is idempotent when symlink already points to correct target', () => { + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + // Call again — should not throw + expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).not.toThrow(); + + const linkPath = path.join(profileDir, 'config.toml'); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); + }); + + it('preserves existing shared config content (does not overwrite)', () => { + fs.writeFileSync(sharedConfigPath, '[model]\nname = "o4"', { mode: 0o600 }); + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('[model]\nname = "o4"'); + }); + + it('replaces a stale symlink pointing to a wrong target with correct one', () => { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + const wrongTarget = path.join(tempDir, 'wrong.toml'); + fs.writeFileSync(wrongTarget, '', { mode: 0o600 }); + const linkPath = path.join(profileDir, 'config.toml'); + fs.symlinkSync(wrongTarget, linkPath); + + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); + }); + + it('replaces a regular file at link path with symlink (with warning)', () => { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + const linkPath = path.join(profileDir, 'config.toml'); + fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 }); + + // Capture stderr to verify warning was written + const stderrChunks: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array): boolean => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return origWrite(chunk); + }; + + try { + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + } finally { + process.stderr.write = origWrite; + } + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); + // A warning should have been emitted + expect(stderrChunks.join('')).toMatch(/overwr|replaced|regular file/i); + }); + + it('replaces a broken symlink (dangling) with correct symlink', () => { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + const linkPath = path.join(profileDir, 'config.toml'); + // Create symlink to non-existent target + fs.symlinkSync(path.join(tempDir, 'does-not-exist.toml'), linkPath); + // Verify it's broken + expect(fs.existsSync(linkPath)).toBe(false); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); + }); +}); diff --git a/tests/unit/codex-auth/codex-profile-paths.test.ts b/tests/unit/codex-auth/codex-profile-paths.test.ts new file mode 100644 index 00000000..647066a4 --- /dev/null +++ b/tests/unit/codex-auth/codex-profile-paths.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as os from 'os'; +import * as path from 'path'; + +let getCodexAuthRegistryPath: () => string; +let getCodexInstancesDir: () => string; +let resolveCodexProfileDir: (name: string) => string; +let getSharedCodexConfigPath: () => string; + +const ORIGINAL_CCS_HOME = process.env.CCS_HOME; + +beforeEach(async () => { + process.env.CCS_HOME = '/tmp/test-ccs-home'; + // Re-import to pick up env change — use dynamic import with cache busting + const mod = await import('../../../src/codex-auth/codex-profile-paths'); + getCodexAuthRegistryPath = mod.getCodexAuthRegistryPath; + getCodexInstancesDir = mod.getCodexInstancesDir; + resolveCodexProfileDir = mod.resolveCodexProfileDir; + getSharedCodexConfigPath = mod.getSharedCodexConfigPath; +}); + +afterEach(() => { + if (ORIGINAL_CCS_HOME === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = ORIGINAL_CCS_HOME; + } +}); + +describe('codex-profile-paths', () => { + it('getCodexAuthRegistryPath returns codex-profiles.yaml inside getCcsDir()', () => { + const result = getCodexAuthRegistryPath(); + expect(result).toContain('codex-profiles.yaml'); + expect(result).toContain('.ccs'); + }); + + it('getCodexInstancesDir returns codex-instances inside getCcsDir()', () => { + const result = getCodexInstancesDir(); + expect(result).toContain('codex-instances'); + expect(result).toContain('.ccs'); + }); + + it('resolveCodexProfileDir returns instancesDir/', () => { + const instancesDir = getCodexInstancesDir(); + const profileDir = resolveCodexProfileDir('work'); + expect(profileDir).toBe(path.join(instancesDir, 'work')); + }); + + it('resolveCodexProfileDir correctly nests a different profile name', () => { + const instancesDir = getCodexInstancesDir(); + const profileDir = resolveCodexProfileDir('personal'); + expect(profileDir).toBe(path.join(instancesDir, 'personal')); + }); + + it('getSharedCodexConfigPath resolves under os.homedir() not getCcsDir()', () => { + const result = getSharedCodexConfigPath(); + // Must equal os.homedir()/.codex/config.toml — uses real homedir, not getCcsDir() + expect(result).toBe(path.join(os.homedir(), '.codex', 'config.toml')); + // Must end with the Codex-canonical path fragment + expect(result).toMatch(/\.codex[/\\]config\.toml$/); + // Must NOT end inside the .ccs directory (i.e. not a CCS-owned path) + expect(result).not.toContain(path.join('.ccs', 'codex')); + }); +}); diff --git a/tests/unit/codex-auth/codex-profile-registry.test.ts b/tests/unit/codex-auth/codex-profile-registry.test.ts new file mode 100644 index 00000000..ca6177f8 --- /dev/null +++ b/tests/unit/codex-auth/codex-profile-registry.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +let CodexProfileRegistry: new (registryPath?: string) => { + createProfile(name: string, meta?: Record): void; + getProfile(name: string): Record; + updateProfile(name: string, partial: Record): void; + removeProfile(name: string): void; + listProfiles(): string[]; + hasProfile(name: string): boolean; + getDefault(): string | null; + setDefault(name: string): void; + clearDefault(): void; + touchProfile(name: string): void; +}; + +let tempDir: string; +let ccsHome: string; +let registryPath: string; + +const ORIGINAL_CCS_HOME = process.env.CCS_HOME; + +beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-registry-test-')); + ccsHome = path.join(tempDir, 'ccs-home'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 }); + process.env.CCS_HOME = ccsHome; + registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); + + const mod = await import('../../../src/codex-auth/codex-profile-registry'); + CodexProfileRegistry = mod.CodexProfileRegistry; +}); + +afterEach(() => { + if (ORIGINAL_CCS_HOME === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = ORIGINAL_CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('CodexProfileRegistry — empty state', () => { + it('returns empty list when registry file does not exist', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(reg.listProfiles()).toEqual([]); + }); + + it('returns null default when registry file does not exist', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(reg.getDefault()).toBeNull(); + }); +}); + +describe('CodexProfileRegistry — create and get', () => { + it('creates a profile and retrieves it by name', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + const profile = reg.getProfile('work'); + expect(profile.type).toBe('codex'); + expect(typeof profile.created).toBe('string'); + expect(profile.last_used).toBeNull(); + }); + + it('persists profile to disk as YAML with schema version', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + const raw = fs.readFileSync(registryPath, 'utf8'); + const parsed = yaml.load(raw) as Record; + expect(parsed.version).toBe('1.0'); + expect(typeof parsed.profiles).toBe('object'); + }); + + it('throws when creating a duplicate profile name', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + expect(() => reg.createProfile('work')).toThrow(/already exists/i); + }); + + it('accepts optional metadata on create', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('personal', { email: 'me@example.com', plan_type: 'pro' }); + const profile = reg.getProfile('personal'); + expect(profile.email).toBe('me@example.com'); + expect(profile.plan_type).toBe('pro'); + }); + + it('hasProfile returns false before creation and true after', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(reg.hasProfile('work')).toBe(false); + reg.createProfile('work'); + expect(reg.hasProfile('work')).toBe(true); + }); +}); + +describe('CodexProfileRegistry — remove', () => { + it('removes an existing profile', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + reg.removeProfile('work'); + expect(reg.listProfiles()).toEqual([]); + }); + + it('throws when removing a non-existent profile', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(() => reg.removeProfile('ghost')).toThrow(/not found/i); + }); + + it('clears default when the default profile is removed', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + reg.setDefault('work'); + expect(reg.getDefault()).toBe('work'); + reg.removeProfile('work'); + expect(reg.getDefault()).toBeNull(); + }); +}); + +describe('CodexProfileRegistry — default pointer', () => { + it('setDefault throws when profile does not exist', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(() => reg.setDefault('ghost')).toThrow(/not found/i); + }); + + it('setDefault and getDefault round-trip', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + reg.setDefault('work'); + expect(reg.getDefault()).toBe('work'); + }); + + it('clearDefault resets default to null', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + reg.setDefault('work'); + reg.clearDefault(); + expect(reg.getDefault()).toBeNull(); + }); +}); + +describe('CodexProfileRegistry — listProfiles', () => { + it('returns all profile names', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + reg.createProfile('personal'); + const list = reg.listProfiles(); + expect(list).toContain('work'); + expect(list).toContain('personal'); + expect(list.length).toBe(2); + }); +}); + +describe('CodexProfileRegistry — corrupt YAML recovery', () => { + it('returns empty state on corrupt YAML without throwing', () => { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, '{ invalid: yaml: content: [', { mode: 0o600 }); + const reg = new CodexProfileRegistry(registryPath); + expect(reg.listProfiles()).toEqual([]); + expect(reg.getDefault()).toBeNull(); + }); +}); + +describe('CodexProfileRegistry — atomic write', () => { + it('leaves no .tmp file after successful write', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + const dir = path.dirname(registryPath); + const tmpFiles = fs.readdirSync(dir).filter((f) => f.includes('.tmp.')); + expect(tmpFiles.length).toBe(0); + }); +}); + +describe('CodexProfileRegistry — touchProfile', () => { + it('updates last_used timestamp', async () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + const before = new Date().toISOString(); + await new Promise((r) => setTimeout(r, 5)); + reg.touchProfile('work'); + const profile = reg.getProfile('work'); + expect(typeof profile.last_used).toBe('string'); + expect((profile.last_used as string) >= before).toBe(true); + }); +}); + +describe('CodexProfileRegistry — updateProfile', () => { + it('merges partial updates into existing profile', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + reg.updateProfile('work', { email: 'updated@example.com', plan_type: 'plus' }); + const profile = reg.getProfile('work'); + expect(profile.email).toBe('updated@example.com'); + expect(profile.plan_type).toBe('plus'); + expect(profile.type).toBe('codex'); + }); + + it('throws when updating a non-existent profile', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(() => reg.updateProfile('ghost', { email: 'x@x.com' })).toThrow(/not found/i); + }); +}); + +describe('CodexProfileRegistry — registry file permissions', () => { + it('writes registry file with mode 0o600', () => { + const reg = new CodexProfileRegistry(registryPath); + reg.createProfile('work'); + const stat = fs.statSync(registryPath); + // On POSIX, check owner read/write only (0o600 = 0b110_000_000 = 384) + expect(stat.mode & 0o777).toBe(0o600); + }); +}); diff --git a/tests/unit/codex-auth/decode-id-token.test.ts b/tests/unit/codex-auth/decode-id-token.test.ts new file mode 100644 index 00000000..9992fddc --- /dev/null +++ b/tests/unit/codex-auth/decode-id-token.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// Lazy import so tests can run before implementation +let decodeIdToken: (idToken: string) => { email?: string; plan_type?: string; account_id?: string }; + +// Fixture: real-shape JWT with nested claims +// Payload (base64url): {"email":"user@example.com","https://api.openai.com/auth":{"chatgpt_plan_type":"pro","chatgpt_account_id":"4b0448c0-e4a2-4cc0-a70d-77065d613553"}} +const VALID_NESTED_TOKEN = buildToken({ + email: 'user@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'pro', + chatgpt_account_id: '4b0448c0-e4a2-4cc0-a70d-77065d613553', + }, +}); + +// Fixture: email only via profile fallback path +const PROFILE_EMAIL_TOKEN = buildToken({ + 'https://api.openai.com/profile': { email: 'profile@example.com' }, + 'https://api.openai.com/auth': { chatgpt_plan_type: 'plus', chatgpt_account_id: 'acct-xyz' }, +}); + +// Fixture: neither top-level email nor profile email — both absent +const NO_EMAIL_TOKEN = buildToken({ + sub: 'user-abc', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'free', + chatgpt_account_id: 'acct-123', + }, +}); + +// Fixture: missing plan_type in auth claim +const MISSING_PLAN_TOKEN = buildToken({ + email: 'user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct-no-plan', + }, +}); + +// Fixture: missing account_id in auth claim +const MISSING_ACCOUNT_ID_TOKEN = buildToken({ + email: 'user@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'pro', + }, +}); + +// Fixture: no https://api.openai.com/auth claim at all +const NO_AUTH_CLAIM_TOKEN = buildToken({ + email: 'user@example.com', + sub: 'user-abc', +}); + +function buildToken(payload: Record): 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`; +} + +beforeEach(async () => { + const mod = await import('../../../src/codex-auth/decode-id-token'); + decodeIdToken = mod.decodeIdToken; +}); + +describe('decodeIdToken', () => { + it('extracts email, plan_type, account_id from standard nested JWT', () => { + const result = decodeIdToken(VALID_NESTED_TOKEN); + expect(result.email).toBe('user@example.com'); + expect(result.plan_type).toBe('pro'); + expect(result.account_id).toBe('4b0448c0-e4a2-4cc0-a70d-77065d613553'); + }); + + it('falls back to profile email when top-level email is absent', () => { + const result = decodeIdToken(PROFILE_EMAIL_TOKEN); + expect(result.email).toBe('profile@example.com'); + expect(result.plan_type).toBe('plus'); + expect(result.account_id).toBe('acct-xyz'); + }); + + it('returns {} for token with only 2 segments (malformed)', () => { + const result = decodeIdToken('header.payload'); + expect(result).toEqual({}); + }); + + it('returns {} for non-base64 garbage input', () => { + const result = decodeIdToken('!!!.%%%.$$$'); + expect(result).toEqual({}); + }); + + it('returns empty object when email is absent in both paths', () => { + const result = decodeIdToken(NO_EMAIL_TOKEN); + expect(result.email).toBeUndefined(); + expect(result.plan_type).toBe('free'); + expect(result.account_id).toBe('acct-123'); + }); + + it('returns undefined plan_type when chatgpt_plan_type is absent', () => { + const result = decodeIdToken(MISSING_PLAN_TOKEN); + expect(result.email).toBe('user@example.com'); + expect(result.plan_type).toBeUndefined(); + expect(result.account_id).toBe('acct-no-plan'); + }); + + it('returns undefined account_id when chatgpt_account_id is absent', () => { + const result = decodeIdToken(MISSING_ACCOUNT_ID_TOKEN); + expect(result.email).toBe('user@example.com'); + expect(result.plan_type).toBe('pro'); + expect(result.account_id).toBeUndefined(); + }); + + it('returns partial result when https://api.openai.com/auth claim is absent', () => { + const result = decodeIdToken(NO_AUTH_CLAIM_TOKEN); + expect(result.email).toBe('user@example.com'); + expect(result.plan_type).toBeUndefined(); + expect(result.account_id).toBeUndefined(); + }); + + it('does not throw on empty string input', () => { + expect(() => decodeIdToken('')).not.toThrow(); + expect(decodeIdToken('')).toEqual({}); + }); +});