feat(codex-auth): add dashboard Auth Profiles tab + GET /api/codex/profiles

Adds a read-only dashboard surface for the codex-auth profile registry.
Power users can see which profile is active and its decoded email/plan
without leaving the browser; mutations stay CLI-only.

- codex-auth-dashboard-service: builds the response shape (active +
  default + profiles[]), reads each profile's auth.json, decodes id_token
  via Phase 1 decoder (nested URI claims per C1), 5s in-memory single-key
  cache plus exported invalidateCodexAuthProfilesCache() hook for
  in-process Phase 2 callers (D7); strict field whitelist — id_token /
  access_token / refresh_token NEVER appear in response body or logs
- GET /api/codex/profiles registered INSIDE the
  requireLocalAccessWhenAuthDisabled middleware (H6) — emails are PII
  and must not leak when dashboard is exposed remotely; integration
  test asserts 403 from non-localhost origin
- NEW Auth Profiles tab (D5) in ui/src/pages/codex.tsx — distinct from
  the existing codex-profiles-card (which edits config.toml [profiles],
  a different concept); active profile + email + plan tier highlighted,
  table of all profiles below, disabled Switch/Remove buttons redirect
  to terminal commands
- accountId returned by API for power users (curl) but hidden from
  the default UI (D6)
- 11 service unit tests + 4 endpoint integration tests, all green
This commit is contained in:
Tam Nhu Tran
2026-05-17 14:45:35 -04:00
parent 8c604a040f
commit e99c4612a8
7 changed files with 1173 additions and 1 deletions
@@ -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<CodexAuthProfilesSummary> {
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<CodexAuthProfilesSummary> {
const now = Date.now();
if (cache && cache.expiresAt > now) {
return cache.value;
}
const value = await buildSummary();
cache = { value, expiresAt: now + TTL_MS };
return value;
}
+16
View File
@@ -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<void> =
}
});
// 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<void> => {
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<void> => {
try {
res.json(await getCodexRawConfig());
@@ -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, 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`;
}
function writeAuthJson(profileDir: string, payload: Record<string, unknown>): 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<void> {
// 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<void>((resolve) => {
server = app.listen(0, '127.0.0.1', () => resolve());
});
port = (server!.address() as { port: number }).port;
}
async function stopApp(): Promise<void> {
if (server) {
await new Promise<void>((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<string, unknown>;
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<string, unknown>;
expect(status).toBe(200);
const profiles = b.profiles as Array<Record<string, unknown>>;
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<string, unknown>;
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<http.Server>((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<void>((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');
});
});
@@ -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, 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`;
}
function writeAuthJson(profileDir: string, idTokenPayload: Record<string, unknown>): 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<string, unknown>)
.map(([k, v]) => {
if (typeof v === 'object' && v !== null) {
const nested = Object.entries(v as Record<string, unknown>)
.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');
});
});
@@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* span wrapper needed — disabled buttons don't trigger mouse events */}
<span tabIndex={0} className="inline-block">
<Button variant="outline" size="sm" disabled className="pointer-events-none">
{label}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{/* TODO i18n: missing key codex.auth.terminalOnlyTooltip */}
Use <code>ccsx auth switch &lt;name&gt;</code> or{' '}
<code>ccsx auth remove &lt;name&gt;</code> in terminal.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// ── Profile table row ────────────────────────────────────────────────────────
function ProfileRow({
entry,
isActive,
activeSource,
}: {
entry: CodexAuthProfileEntry;
isActive: boolean;
activeSource?: 'default' | 'env' | 'explicit-codex-home';
}) {
return (
<TableRow className={isActive ? 'bg-muted/40' : undefined}>
<TableCell className="font-medium">
<span className="flex items-center gap-2">
{entry.name}
{isActive && activeSource && (
<Badge variant="secondary" className="text-xs">
{/* TODO i18n: missing key codex.auth.activeSourceBadge */}
{sourceLabel(activeSource)}
</Badge>
)}
</span>
</TableCell>
<TableCell>{entry.email ?? '—'}</TableCell>
<TableCell>{entry.plan ?? '—'}</TableCell>
<TableCell>{formatLastUsed(entry.lastUsed)}</TableCell>
<TableCell>
{entry.authValid ? (
<Badge variant="secondary" className="text-xs text-green-700 dark:text-green-400">
{/* TODO i18n: missing key codex.auth.statusOk */}
OK
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
{/* TODO i18n: missing key codex.auth.statusInvalid */}
[!] auth invalid
</Badge>
)}
</TableCell>
<TableCell>
<span className="flex gap-1">
<TerminalOnlyButton label="Switch" />
<TerminalOnlyButton label="Remove" />
</span>
</TableCell>
</TableRow>
);
}
// ── Main card ────────────────────────────────────────────────────────────────
export function CodexAuthProfilesCard() {
const { data, isLoading, error } = useCodexAuthProfiles();
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
<Loader2 className="h-4 w-4 animate-spin" />
{/* TODO i18n: missing key codex.auth.loading */}
Loading auth profiles...
</div>
);
}
if (error || !data) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{/* TODO i18n: missing key codex.auth.loadError */}
[!] Failed to load codex-auth profiles.
</div>
);
}
// Empty registry — no profiles at all
if (data.profiles.length === 0) {
return (
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1">
<p>
{/* TODO i18n: missing key codex.auth.emptyRegistry */}
[i] No codex-auth profiles. Run{' '}
<code className="rounded bg-muted px-1">ccsx auth create &lt;name&gt;</code> to create
one.
</p>
<p>Codex will use the default ~/.codex location.</p>
</div>
);
}
// Legacy mode — profiles exist but none active
if (!data.active) {
return (
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{/* TODO i18n: missing key codex.auth.legacyMode */}
[i] No active profile. Using ~/.codex (legacy). Run{' '}
<code className="rounded bg-muted px-1">ccsx auth switch &lt;name&gt;</code> in terminal
to activate one.
</div>
<ProfileTable data={data} />
</div>
);
}
// External CODEX_HOME with no registry match
if (data.active.source === 'explicit-codex-home' && data.active.name === null) {
return (
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{/* TODO i18n: missing key codex.auth.externalCodexHome */}
[i] $CODEX_HOME set externally to{' '}
<code className="rounded bg-muted px-1">{data.active.codexHome}</code>. Profile registry
not in use for this session.
</div>
<ProfileTable data={data} />
</div>
);
}
return (
<div className="space-y-3">
<ActiveBanner name={data.active.name} source={data.active.source} profiles={data.profiles} />
<ProfileTable data={data} />
</div>
);
}
// ── 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 (
<div className="rounded-md border bg-muted/20 px-4 py-3 text-sm space-y-1">
<div className="flex items-center gap-2 font-medium">
{/* TODO i18n: missing key codex.auth.activeProfile */}
Active profile:
<span>{name ?? '(unknown)'}</span>
<Badge variant="secondary" className="text-xs">
{sourceLabel(source)}
</Badge>
</div>
{activeEntry && (
<div className="text-muted-foreground text-xs space-x-3">
{activeEntry.email && <span>{activeEntry.email}</span>}
{activeEntry.plan && (
<span>
Plan: <strong>{activeEntry.plan}</strong>
</span>
)}
{!activeEntry.authValid && <span className="text-destructive">[!] auth invalid</span>}
</div>
)}
</div>
);
}
// ── Profile table ────────────────────────────────────────────────────────────
function ProfileTable({
data,
}: {
data: {
active: { name: string | null; source: 'default' | 'env' | 'explicit-codex-home' } | null;
profiles: CodexAuthProfileEntry[];
};
}) {
return (
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
<TableRow>
{/* TODO i18n: missing keys codex.auth.col.name/email/plan/lastUsed/status/actions */}
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Last used</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.profiles.map((entry) => (
<ProfileRow
key={entry.name}
entry={entry}
isActive={data.active?.name === entry.name}
activeSource={data.active?.name === entry.name ? data.active.source : undefined}
/>
))}
</TableBody>
</Table>
</div>
);
}
+51
View File
@@ -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<CodexAuthProfilesResponse> {
const res = await fetch(withApiBase('/codex/profiles'));
if (!res.ok) {
throw new Error('Failed to fetch Codex auth profiles');
}
return res.json() as Promise<CodexAuthProfilesResponse>;
}
export function useCodexAuthProfiles() {
return useQuery({
queryKey: ['codex-auth-profiles'],
queryFn: fetchCodexAuthProfiles,
refetchInterval: 15000,
});
}
+12 -1
View File
@@ -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 (
<Tabs defaultValue="overview" className="flex h-full flex-col">
<div className="shrink-0 px-4 pt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger>
<TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger>
<TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger>
<TabsTrigger value="auth-profiles">
{/* TODO i18n: missing key codexPage.authProfiles */}
Auth Profiles
</TabsTrigger>
</TabsList>
</div>
@@ -211,6 +216,12 @@ export function CodexPage() {
<TabsContent value="docs" className={tabContentClassName}>
<CodexDocsTab diagnostics={diagnostics} />
</TabsContent>
<TabsContent value="auth-profiles" className={tabContentClassName}>
<div className="h-full overflow-auto p-1">
<CodexAuthProfilesCard />
</div>
</TabsContent>
</div>
</Tabs>
);