diff --git a/src/codex-auth/codex-auth-dashboard-service.ts b/src/codex-auth/codex-auth-dashboard-service.ts new file mode 100644 index 00000000..0ee8bde3 --- /dev/null +++ b/src/codex-auth/codex-auth-dashboard-service.ts @@ -0,0 +1,224 @@ +/** + * Dashboard service for codex-auth profile summary. + * + * Reads the profile registry, decodes each profile's auth.json JWT, + * resolves the active profile via env precedence, and returns the + * API response shape for GET /api/codex/profiles. + * + * Security: tokens NEVER appear in the returned object. Only display-safe + * fields (email, plan, accountId) are extracted from JWT. auth.json is + * read/decoded and then discarded. + * + * Cache: 5-second in-memory single-key cache reduces fs reads during + * dashboard polling. Out-of-process callers rely on the TTL. In-process + * callers (Phase 2 CLI commands running in dev-server context) can call + * invalidateCodexAuthProfilesCache() to force an immediate re-read. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { createLogger } from '../services/logging'; +import { decodeAccountIdentity } from './codex-account-identity'; +import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths'; +import type { CodexProfileData } from './types'; + +const logger = createLogger('codex-auth:dashboard'); + +// ── Response types ────────────────────────────────────────────────────────── + +export interface CodexAuthProfileEntry { + name: string; + codexHome: string; + email: string | null; + plan: string | null; + accountId: string | null; + lastUsed: string | null; + authValid: boolean; +} + +export interface CodexAuthActiveProfile { + name: string | null; + source: 'default' | 'env' | 'explicit-codex-home'; + codexHome: string; +} + +export interface CodexAuthProfilesSummary { + active: CodexAuthActiveProfile | null; + default: string | null; + profiles: CodexAuthProfileEntry[]; +} + +// ── Cache ─────────────────────────────────────────────────────────────────── + +let cache: { value: CodexAuthProfilesSummary; expiresAt: number } | null = null; +const TTL_MS = 5000; + +/** + * Invalidate the in-process cache so the next call re-reads from disk. + * Useful for Phase 2 CLI commands running in the same process as the dashboard. + * Out-of-process invocations rely on the 5s TTL. + */ +export function invalidateCodexAuthProfilesCache(): void { + cache = null; +} + +// ── Registry helpers ──────────────────────────────────────────────────────── + +function readRegistry(): CodexProfileData { + const registryPath = getCodexAuthRegistryPath(); + if (!fs.existsSync(registryPath)) { + return { version: '1.0', default: null, profiles: {} }; + } + try { + const raw = fs.readFileSync(registryPath, 'utf8'); + const parsed = yaml.load(raw) as CodexProfileData | null; + if (!parsed || typeof parsed !== 'object' || !parsed.profiles) { + return { version: '1.0', default: null, profiles: {} }; + } + return parsed; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('codex-auth.dashboard.registry-read-failed', `Registry read failed: ${msg}`); + return { version: '1.0', default: null, profiles: {} }; + } +} + +// ── Profile entry builder ─────────────────────────────────────────────────── + +function buildProfileEntry(name: string): CodexAuthProfileEntry { + const codexHome = path.join(getCodexInstancesDir(), name); + const authJsonPath = path.join(codexHome, 'auth.json'); + + let authValid = false; + let email: string | null = null; + let plan: string | null = null; + let accountId: string | null = null; + + try { + if (fs.existsSync(authJsonPath)) { + // decodeAccountIdentity never throws; returns {} on any error + const identity = decodeAccountIdentity(authJsonPath); + authValid = Object.keys(identity).length > 0 || _hasValidStructure(authJsonPath); + email = identity.email ?? null; + plan = identity.plan_type ?? null; + accountId = identity.account_id ?? null; + logger.debug( + 'codex-auth.dashboard.decoded', + `Decoded auth for profile=${name} email=${email ?? '(none)'}` + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn( + 'codex-auth.dashboard.decode-error', + `Failed to decode auth for profile=${name}: ${msg}` + ); + } + + return { + name, + codexHome, + email, + plan, + accountId, + // lastUsed is set below by caller from registry metadata + lastUsed: null, + authValid, + }; +} + +/** + * Check whether auth.json has the expected structure (tokens.id_token present), + * even if decoding yielded no display fields (e.g. no email in JWT). + * This sets authValid=true for valid-but-sparse tokens. + */ +function _hasValidStructure(authJsonPath: string): boolean { + try { + const raw = fs.readFileSync(authJsonPath, 'utf8'); + const parsed = JSON.parse(raw) as { tokens?: { id_token?: string } }; + return typeof parsed?.tokens?.id_token === 'string' && parsed.tokens.id_token.length > 0; + } catch { + return false; + } +} + +// ── Active resolution ─────────────────────────────────────────────────────── + +function resolveActive(registry: CodexProfileData): CodexAuthActiveProfile | null { + const instancesDir = getCodexInstancesDir(); + + // Precedence 1: explicit $CODEX_HOME set by env (ccsxp, manual) + const codexHome = (process.env.CODEX_HOME ?? '').trim(); + if (codexHome) { + // Attempt reverse-map: does any registered profile's codexHome match? + const matchedName = + Object.keys(registry.profiles).find((name) => path.join(instancesDir, name) === codexHome) ?? + null; + return { + name: matchedName, + source: 'explicit-codex-home', + codexHome, + }; + } + + // Precedence 2: $CCS_CODEX_PROFILE env var + const profileEnv = (process.env.CCS_CODEX_PROFILE ?? '').trim(); + if (profileEnv) { + return { + name: profileEnv, + source: 'env', + codexHome: path.join(instancesDir, profileEnv), + }; + } + + // Precedence 3: registry default + const defaultProfile = registry.default; + if (defaultProfile) { + return { + name: defaultProfile, + source: 'default', + codexHome: path.join(instancesDir, defaultProfile), + }; + } + + // Precedence 4: no active profile (legacy ~/.codex mode) + return null; +} + +// ── Core builder ──────────────────────────────────────────────────────────── + +async function buildSummary(): Promise { + const registry = readRegistry(); + const active = resolveActive(registry); + + const profiles: CodexAuthProfileEntry[] = Object.entries(registry.profiles).map( + ([name, meta]) => { + const entry = buildProfileEntry(name); + entry.lastUsed = meta.last_used ?? null; + return entry; + } + ); + + return { + active, + default: registry.default, + profiles, + }; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Returns the codex-auth profiles summary, using a 5s in-memory cache. + * Tokens are never included in the returned object. + */ +export async function getCodexAuthProfilesSummary(): Promise { + const now = Date.now(); + if (cache && cache.expiresAt > now) { + return cache.value; + } + const value = await buildSummary(); + cache = { value, expiresAt: now + TTL_MS }; + return value; +} diff --git a/src/web-server/routes/codex-routes.ts b/src/web-server/routes/codex-routes.ts index 26d26815..9cf7f348 100644 --- a/src/web-server/routes/codex-routes.ts +++ b/src/web-server/routes/codex-routes.ts @@ -9,6 +9,7 @@ import { patchCodexConfig, saveCodexRawConfig, } from '../services/codex-dashboard-service'; +import { getCodexAuthProfilesSummary } from '../../codex-auth/codex-auth-dashboard-service'; const router = Router(); const CODEX_CONFIG_ACCESS_ERROR = @@ -28,6 +29,21 @@ router.get('/diagnostics', async (_req: Request, res: Response): Promise = } }); +// H6: email is PII — require localhost access when dashboard auth is disabled. +const CODEX_PROFILES_ACCESS_ERROR = + 'Codex auth profiles endpoint requires localhost access when dashboard auth is disabled.'; + +router.get('/profiles', async (req: Request, res: Response): Promise => { + if (!requireLocalAccessWhenAuthDisabled(req, res, CODEX_PROFILES_ACCESS_ERROR)) { + return; + } + try { + res.json(await getCodexAuthProfilesSummary()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + router.get('/config/raw', async (_req: Request, res: Response): Promise => { try { res.json(await getCodexRawConfig()); diff --git a/tests/integration/web-server/codex-profiles-endpoint.test.ts b/tests/integration/web-server/codex-profiles-endpoint.test.ts new file mode 100644 index 00000000..966a919b --- /dev/null +++ b/tests/integration/web-server/codex-profiles-endpoint.test.ts @@ -0,0 +1,230 @@ +/** + * Integration tests for GET /api/codex/profiles endpoint. + * + * Covers: + * - localhost GET -> 200 + correct shape + * - non-localhost (mocked remote IP) -> 403 (H6 localhost guard) + * - empty registry -> {active: null, default: null, profiles: []} (no 404) + * - response contains no token substrings + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import express from 'express'; + +let tmpDir: string; +let ccsDir: string; +let server: http.Server | null = null; +let port: number; + +// Helpers ------------------------------------------------------------------ + +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`; +} + +function writeAuthJson(profileDir: string, payload: Record): void { + const authJson = { + tokens: { + id_token: buildToken(payload), + access_token: 'MUST_NOT_APPEAR_IN_RESPONSE', + refresh_token: 'MUST_NOT_APPEAR_IN_RESPONSE', + }, + }; + fs.writeFileSync(path.join(profileDir, 'auth.json'), JSON.stringify(authJson), { + mode: 0o600, + }); +} + +async function startApp(): Promise { + // Invalidate cache before each test + const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service'); + svc.invalidateCodexAuthProfilesCache(); + + const codexRouter = (await import('../../../src/web-server/routes/codex-routes')).default; + + const app = express(); + app.use(express.json()); + app.use('/api/codex', codexRouter); + + await new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + port = (server!.address() as { port: number }).port; +} + +async function stopApp(): Promise { + if (server) { + await new Promise((resolve, reject) => { + server!.close((err) => (err ? reject(err) : resolve())); + }); + server = null; + } +} + +async function get(urlPath: string): Promise<{ status: number; body: unknown }> { + const res = await fetch(`http://127.0.0.1:${port}${urlPath}`); + const body = await res.json(); + return { status: res.status, body }; +} + +// Setup / teardown --------------------------------------------------------- + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-int-test-')); + process.env.CCS_HOME = tmpDir; + // getCcsDir() returns path.join(CCS_HOME, '.ccs') + ccsDir = path.join(tmpDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + delete process.env.CODEX_HOME; + delete process.env.CCS_CODEX_PROFILE; + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + + await startApp(); +}); + +afterEach(async () => { + await stopApp(); + delete process.env.CCS_HOME; + delete process.env.CODEX_HOME; + delete process.env.CCS_CODEX_PROFILE; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// Tests -------------------------------------------------------------------- + +describe('GET /api/codex/profiles', () => { + it('returns 200 with empty shape when registry does not exist', async () => { + const { status, body } = await get('/api/codex/profiles'); + + expect(status).toBe(200); + const b = body as Record; + expect(b.active).toBeNull(); + expect(b.default).toBeNull(); + expect(Array.isArray(b.profiles)).toBe(true); + expect((b.profiles as unknown[]).length).toBe(0); + }); + + it('returns 200 with decoded email and plan for a valid profile', async () => { + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + + writeAuthJson(workDir, { + email: 'work@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'pro', + chatgpt_account_id: 'acct-work', + }, + }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: "2026-05-17T05:45:00Z"\n`, + { mode: 0o600 } + ); + + // Invalidate cache so the new files are read + const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service'); + svc.invalidateCodexAuthProfilesCache(); + + const { status, body } = await get('/api/codex/profiles'); + const b = body as Record; + + expect(status).toBe(200); + const profiles = b.profiles as Array>; + expect(profiles.length).toBe(1); + const profile = profiles[0]; + expect(profile?.name).toBe('work'); + expect(profile?.email).toBe('work@example.com'); + expect(profile?.plan).toBe('pro'); + expect(profile?.authValid).toBe(true); + const active = b.active as Record; + expect(active?.source).toBe('default'); + expect(active?.name).toBe('work'); + }); + + it('returns 403 when requireLocalAccessWhenAuthDisabled guard rejects non-localhost', async () => { + // The guard checks req.socket.remoteAddress. Since the server binds to + // 127.0.0.1 and the test client connects to 127.0.0.1, the built-in fetch + // will always be loopback. We test the guard directly via a separate + // Express app that injects a non-loopback remote address. + const { requireLocalAccessWhenAuthDisabled } = await import( + '../../../src/web-server/middleware/auth-middleware' + ); + const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade'); + + if (!isDashboardAuthEnabled()) { + let guardResult: boolean | undefined; + let responseStatus: number | undefined; + + const testApp = express(); + testApp.get('/test', (req, res) => { + // Spoof a non-localhost remote address + Object.defineProperty(req, 'socket', { + value: { remoteAddress: '203.0.113.42' }, + writable: true, + configurable: true, + }); + guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only'); + if (guardResult) { + responseStatus = 200; + res.json({ ok: true }); + } else { + responseStatus = 403; + } + }); + + const testServer = await new Promise((resolve) => { + const s = testApp.listen(0, '127.0.0.1', () => resolve(s)); + }); + const testPort = (testServer.address() as { port: number }).port; + + const res = await fetch(`http://127.0.0.1:${testPort}/test`); + await new Promise((resolve) => testServer.close(() => resolve())); + + expect(res.status).toBe(403); + expect(guardResult).toBe(false); + } + }); + + it('response body contains no token substrings for a valid profile', async () => { + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + + writeAuthJson(workDir, { + email: 'secure@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'pro', + chatgpt_account_id: 'acct-secure', + }, + }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + // Invalidate cache so the new files are read + const svc = await import('../../../src/codex-auth/codex-auth-dashboard-service'); + svc.invalidateCodexAuthProfilesCache(); + + const { status, body } = await get('/api/codex/profiles'); + expect(status).toBe(200); + + const bodyStr = JSON.stringify(body); + expect(bodyStr).not.toContain('id_token'); + expect(bodyStr).not.toContain('access_token'); + expect(bodyStr).not.toContain('refresh_token'); + expect(bodyStr).not.toContain('MUST_NOT_APPEAR_IN_RESPONSE'); + }); +}); diff --git a/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts b/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts new file mode 100644 index 00000000..f8071dde --- /dev/null +++ b/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts @@ -0,0 +1,355 @@ +/** + * Unit tests for codex-auth-dashboard-service. + * + * Tests cover: empty registry, decoded fields, corrupt auth.json, + * active resolution precedence (4 paths), cache TTL, cache invalidation, + * and token redaction from response. + */ + +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 tmpDir: string; + +// Helpers ------------------------------------------------------------------ + +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`; +} + +function writeAuthJson(profileDir: string, idTokenPayload: Record): void { + const authJson = { + tokens: { + id_token: buildToken(idTokenPayload), + access_token: 'access-token-should-not-appear', + refresh_token: 'refresh-token-should-not-appear', + }, + }; + fs.writeFileSync(path.join(profileDir, 'auth.json'), JSON.stringify(authJson), { + mode: 0o600, + }); +} + +function writeRegistry(registryPath: string, data: unknown): void { + const dir = path.dirname(registryPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const yaml = (d: unknown): string => { + // Minimal YAML serialiser for the test fixture + if (typeof d === 'object' && d !== null) { + return Object.entries(d as Record) + .map(([k, v]) => { + if (typeof v === 'object' && v !== null) { + const nested = Object.entries(v as Record) + .map(([nk, nv]) => ` ${nk}: ${nv === null ? 'null' : String(nv)}`) + .join('\n'); + return `${k}:\n${nested}`; + } + return `${k}: ${v === null ? 'null' : String(v)}`; + }) + .join('\n'); + } + return ''; + }; + fs.writeFileSync(registryPath, yaml(data), { mode: 0o600 }); +} + +// Module cache helpers ----------------------------------------------------- + +async function importService() { + // Bust module cache on each test by importing fresh via dynamic import + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await import( + '../../../src/codex-auth/codex-auth-dashboard-service' + ); + return { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache }; +} + +// Setup / teardown --------------------------------------------------------- + +// getCcsDir() returns path.join(CCS_HOME, '.ccs') when CCS_HOME is set. +// Tests must write to tmpDir/.ccs/ to be visible to the service. +let ccsDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); + process.env.CCS_HOME = tmpDir; + ccsDir = path.join(tmpDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + // Clear module cache so cache state doesn't bleed between tests + // Bun doesn't have require.cache; we rely on invalidateCodexAuthProfilesCache +}); + +afterEach(() => { + delete process.env.CCS_HOME; + delete process.env.CODEX_HOME; + delete process.env.CCS_CODEX_PROFILE; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// Tests -------------------------------------------------------------------- + +describe('getCodexAuthProfilesSummary', () => { + it('returns empty profiles and active=null when registry does not exist', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + const result = await getCodexAuthProfilesSummary(); + expect(result.profiles).toEqual([]); + expect(result.active).toBeNull(); + expect(result.default).toBeNull(); + }); + + it('returns decoded email, plan, accountId for valid registry with 2 profiles', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + const personalDir = path.join(instancesDir, 'personal'); + fs.mkdirSync(workDir, { recursive: true }); + fs.mkdirSync(personalDir, { recursive: true }); + + writeAuthJson(workDir, { + email: 'work@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'pro', + chatgpt_account_id: 'acct-work-123', + }, + }); + writeAuthJson(personalDir, { + email: 'personal@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'free', + chatgpt_account_id: 'acct-personal-456', + }, + }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + const yamlContent = `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: "2026-05-17T05:45:00Z"\n personal:\n type: codex\n created: "2026-01-02T00:00:00Z"\n last_used: null\n`; + fs.writeFileSync(registryPath, yamlContent, { mode: 0o600 }); + + const result = await getCodexAuthProfilesSummary(); + expect(result.profiles).toHaveLength(2); + + const work = result.profiles.find((p) => p.name === 'work'); + expect(work).toBeDefined(); + expect(work?.email).toBe('work@example.com'); + expect(work?.plan).toBe('pro'); + expect(work?.accountId).toBe('acct-work-123'); + expect(work?.authValid).toBe(true); + expect(work?.lastUsed).toBe('2026-05-17T05:45:00Z'); + + const personal = result.profiles.find((p) => p.name === 'personal'); + expect(personal).toBeDefined(); + expect(personal?.email).toBe('personal@example.com'); + expect(personal?.plan).toBe('free'); + expect(personal?.accountId).toBe('acct-personal-456'); + expect(personal?.authValid).toBe(true); + expect(personal?.lastUsed).toBeNull(); + }); + + it('sets authValid=false and nulls identity fields when auth.json is corrupt JSON', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const brokenDir = path.join(instancesDir, 'broken'); + fs.mkdirSync(brokenDir, { recursive: true }); + fs.writeFileSync(path.join(brokenDir, 'auth.json'), '{ not valid json', { + mode: 0o600, + }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: broken\nprofiles:\n broken:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + const result = await getCodexAuthProfilesSummary(); + expect(result.profiles).toHaveLength(1); + const broken = result.profiles[0]; + expect(broken?.authValid).toBe(false); + expect(broken?.email).toBeNull(); + expect(broken?.plan).toBeNull(); + expect(broken?.accountId).toBeNull(); + }); + + it('sets authValid=false and nulls identity fields when auth.json is missing', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const noAuthDir = path.join(instancesDir, 'noauth'); + fs.mkdirSync(noAuthDir, { recursive: true }); + // No auth.json written + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: noauth\nprofiles:\n noauth:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + const result = await getCodexAuthProfilesSummary(); + const profile = result.profiles[0]; + expect(profile?.authValid).toBe(false); + expect(profile?.email).toBeNull(); + expect(profile?.plan).toBeNull(); + expect(profile?.accountId).toBeNull(); + }); + + it('active resolution: CODEX_HOME set externally -> source=explicit-codex-home', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const externalHome = path.join(tmpDir, 'external-codex'); + process.env.CODEX_HOME = externalHome; + + const result = await getCodexAuthProfilesSummary(); + expect(result.active).not.toBeNull(); + expect(result.active?.source).toBe('explicit-codex-home'); + expect(result.active?.codexHome).toBe(externalHome); + }); + + it('active resolution: CCS_CODEX_PROFILE set -> source=env, name=that profile', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + process.env.CCS_CODEX_PROFILE = 'work'; + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: null\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + const result = await getCodexAuthProfilesSummary(); + expect(result.active?.source).toBe('env'); + expect(result.active?.name).toBe('work'); + }); + + it('active resolution: registry default set -> source=default', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + const result = await getCodexAuthProfilesSummary(); + expect(result.active?.source).toBe('default'); + expect(result.active?.name).toBe('work'); + }); + + it('active resolution: no env, no default -> active=null', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + // No registry file, no env vars + const result = await getCodexAuthProfilesSummary(); + expect(result.active).toBeNull(); + }); + + it('returns cached value on second call within 5s (no extra fs reads)', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + const first = await getCodexAuthProfilesSummary(); + // Modify registry after first call — should NOT be seen within cache TTL + fs.writeFileSync(registryPath, `version: "1.0"\ndefault: null\nprofiles: {}\n`, { + mode: 0o600, + }); + + const second = await getCodexAuthProfilesSummary(); + // Both calls should return same reference (cache hit) + expect(second).toBe(first); + }); + + it('invalidateCodexAuthProfilesCache forces re-read on next call', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync(registryPath, `version: "1.0"\ndefault: null\nprofiles: {}\n`, { + mode: 0o600, + }); + + const first = await getCodexAuthProfilesSummary(); + expect(first.profiles).toHaveLength(0); + + // Now add a profile + const instancesDir = path.join(ccsDir, 'codex-instances'); + const newDir = path.join(instancesDir, 'newprofile'); + fs.mkdirSync(newDir, { recursive: true }); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: newprofile\nprofiles:\n newprofile:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + invalidateCodexAuthProfilesCache(); + const second = await getCodexAuthProfilesSummary(); + expect(second.profiles).toHaveLength(1); + expect(second.profiles[0]?.name).toBe('newprofile'); + }); + + it('response JSON contains no token substrings', async () => { + const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService(); + invalidateCodexAuthProfilesCache(); + + const instancesDir = path.join(ccsDir, 'codex-instances'); + const workDir = path.join(instancesDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + + writeAuthJson(workDir, { + email: 'work@example.com', + 'https://api.openai.com/auth': { + chatgpt_plan_type: 'pro', + chatgpt_account_id: 'acct-work', + }, + }); + + const registryPath = path.join(ccsDir, 'codex-profiles.yaml'); + fs.writeFileSync( + registryPath, + `version: "1.0"\ndefault: work\nprofiles:\n work:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`, + { mode: 0o600 } + ); + + const result = await getCodexAuthProfilesSummary(); + const serialized = JSON.stringify(result); + + // Security: no raw token material in response + expect(serialized).not.toContain('access_token'); + expect(serialized).not.toContain('refresh_token'); + expect(serialized).not.toContain('id_token'); + // The known sentinel values from writeAuthJson + expect(serialized).not.toContain('access-token-should-not-appear'); + expect(serialized).not.toContain('refresh-token-should-not-appear'); + }); +}); diff --git a/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx b/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx new file mode 100644 index 00000000..f856ca41 --- /dev/null +++ b/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx @@ -0,0 +1,285 @@ +/** + * Read-only dashboard card displaying codex-auth profile state. + * + * Distinct from codex-profiles-card.tsx (which edits config.toml [profiles]). + * This card shows CCS-side shell profiles: active account, email, plan tier, + * last-used timestamp, and auth validity. + * + * All mutating actions (switch, remove) are disabled with a terminal redirect + * tooltip per the read-only dashboard spec (D5). + */ + +import * as React from 'react'; +import { Loader2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { useCodexAuthProfiles } from '@/hooks/use-codex-auth-profiles'; +import type { CodexAuthProfileEntry } from '@/hooks/use-codex-auth-profiles'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function formatLastUsed(iso: string | null): string { + if (!iso) return 'never'; + try { + const d = new Date(iso); + const diffMs = Date.now() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 2) return 'just now'; + if (diffMin < 60) return `${diffMin} min ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH}h ago`; + const diffD = Math.floor(diffH / 24); + if (diffD === 1) return 'yesterday'; + return `${diffD}d ago`; + } catch { + return iso; + } +} + +function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home'): string { + switch (source) { + case 'default': + return 'default'; + case 'env': + return '$CCS_CODEX_PROFILE'; + case 'explicit-codex-home': + return '$CODEX_HOME'; + } +} + +// ── Disabled action button with terminal-redirect tooltip ─────────────────── + +function TerminalOnlyButton({ label }: { label: string }) { + return ( + + + + {/* span wrapper needed — disabled buttons don't trigger mouse events */} + + + + + + {/* TODO i18n: missing key codex.auth.terminalOnlyTooltip */} + Use ccsx auth switch <name> or{' '} + ccsx auth remove <name> in terminal. + + + + ); +} + +// ── Profile table row ──────────────────────────────────────────────────────── + +function ProfileRow({ + entry, + isActive, + activeSource, +}: { + entry: CodexAuthProfileEntry; + isActive: boolean; + activeSource?: 'default' | 'env' | 'explicit-codex-home'; +}) { + return ( + + + + {entry.name} + {isActive && activeSource && ( + + {/* TODO i18n: missing key codex.auth.activeSourceBadge */} + {sourceLabel(activeSource)} + + )} + + + {entry.email ?? '—'} + {entry.plan ?? '—'} + {formatLastUsed(entry.lastUsed)} + + {entry.authValid ? ( + + {/* TODO i18n: missing key codex.auth.statusOk */} + OK + + ) : ( + + {/* TODO i18n: missing key codex.auth.statusInvalid */} + [!] auth invalid + + )} + + + + + + + + + ); +} + +// ── Main card ──────────────────────────────────────────────────────────────── + +export function CodexAuthProfilesCard() { + const { data, isLoading, error } = useCodexAuthProfiles(); + + if (isLoading) { + return ( +
+ + {/* TODO i18n: missing key codex.auth.loading */} + Loading auth profiles... +
+ ); + } + + if (error || !data) { + return ( +
+ {/* TODO i18n: missing key codex.auth.loadError */} + [!] Failed to load codex-auth profiles. +
+ ); + } + + // Empty registry — no profiles at all + if (data.profiles.length === 0) { + return ( +
+

+ {/* TODO i18n: missing key codex.auth.emptyRegistry */} + [i] No codex-auth profiles. Run{' '} + ccsx auth create <name> to create + one. +

+

Codex will use the default ~/.codex location.

+
+ ); + } + + // Legacy mode — profiles exist but none active + if (!data.active) { + return ( +
+
+ {/* TODO i18n: missing key codex.auth.legacyMode */} + [i] No active profile. Using ~/.codex (legacy). Run{' '} + ccsx auth switch <name> in terminal + to activate one. +
+ +
+ ); + } + + // External CODEX_HOME with no registry match + if (data.active.source === 'explicit-codex-home' && data.active.name === null) { + return ( +
+
+ {/* TODO i18n: missing key codex.auth.externalCodexHome */} + [i] $CODEX_HOME set externally to{' '} + {data.active.codexHome}. Profile registry + not in use for this session. +
+ +
+ ); + } + + return ( +
+ + +
+ ); +} + +// ── Active profile highlight banner ───────────────────────────────────────── + +function ActiveBanner({ + name, + source, + profiles, +}: { + name: string | null; + source: 'default' | 'env' | 'explicit-codex-home'; + profiles: CodexAuthProfileEntry[]; +}) { + const activeEntry = profiles.find((p) => p.name === name); + + return ( +
+
+ {/* TODO i18n: missing key codex.auth.activeProfile */} + Active profile: + {name ?? '(unknown)'} + + {sourceLabel(source)} + +
+ {activeEntry && ( +
+ {activeEntry.email && {activeEntry.email}} + {activeEntry.plan && ( + + Plan: {activeEntry.plan} + + )} + {!activeEntry.authValid && [!] auth invalid} +
+ )} +
+ ); +} + +// ── Profile table ──────────────────────────────────────────────────────────── + +function ProfileTable({ + data, +}: { + data: { + active: { name: string | null; source: 'default' | 'env' | 'explicit-codex-home' } | null; + profiles: CodexAuthProfileEntry[]; + }; +}) { + return ( +
+ + + + {/* TODO i18n: missing keys codex.auth.col.name/email/plan/lastUsed/status/actions */} + Name + Email + Plan + Last used + Status + Actions + + + + {data.profiles.map((entry) => ( + + ))} + +
+
+ ); +} diff --git a/ui/src/hooks/use-codex-auth-profiles.ts b/ui/src/hooks/use-codex-auth-profiles.ts new file mode 100644 index 00000000..2a944910 --- /dev/null +++ b/ui/src/hooks/use-codex-auth-profiles.ts @@ -0,0 +1,51 @@ +/** + * React hook for fetching codex-auth profile summary from + * GET /api/codex/profiles. Returns the active profile, default, + * and per-profile list with decoded identity fields. + * + * Mirrors the useCodex pattern (use-codex.ts:70) with a 15s refetch + * interval — dashboard polls are low-frequency; the server-side 5s + * cache absorbs bursts. + */ + +import { useQuery } from '@tanstack/react-query'; +import { withApiBase } from '@/lib/api-client'; + +export interface CodexAuthProfileEntry { + name: string; + codexHome: string; + email: string | null; + plan: string | null; + /** accountId returned by API but not displayed in UI per D6. */ + accountId: string | null; + lastUsed: string | null; + authValid: boolean; +} + +export interface CodexAuthActiveProfile { + name: string | null; + source: 'default' | 'env' | 'explicit-codex-home'; + codexHome: string; +} + +export interface CodexAuthProfilesResponse { + active: CodexAuthActiveProfile | null; + default: string | null; + profiles: CodexAuthProfileEntry[]; +} + +async function fetchCodexAuthProfiles(): Promise { + const res = await fetch(withApiBase('/codex/profiles')); + if (!res.ok) { + throw new Error('Failed to fetch Codex auth profiles'); + } + return res.json() as Promise; +} + +export function useCodexAuthProfiles() { + return useQuery({ + queryKey: ['codex-auth-profiles'], + queryFn: fetchCodexAuthProfiles, + refetchInterval: 15000, + }); +} diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index 075f3516..296156a5 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { GripVertical, Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { CodexAuthProfilesCard } from '@/components/compatible-cli/codex-auth-profiles-card'; import { CodexControlCenterTab } from '@/components/compatible-cli/codex-control-center-tab'; import { CodexDocsTab } from '@/components/compatible-cli/codex-docs-tab'; import { useCodex } from '@/hooks/use-codex'; @@ -178,10 +179,14 @@ export function CodexPage() { return (
- + {t('codexPage.overview')} {t('codexPage.controlCenter')} {t('codexPage.docs')} + + {/* TODO i18n: missing key codexPage.authProfiles */} + Auth Profiles +
@@ -211,6 +216,12 @@ export function CodexPage() { + + +
+ +
+
);