fix(codex-auth): harden registry fail-closed paths

This commit is contained in:
Tam Nhu Tran
2026-05-17 18:20:56 -04:00
parent 54738e88f7
commit 81c4acc73a
10 changed files with 269 additions and 41 deletions
+26 -7
View File
@@ -9,10 +9,9 @@
* 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.
* Cache: 5-second in-memory cache reduces full registry/auth reads during
* dashboard polling, but each call still stats the registry so corruption or
* edits are surfaced immediately instead of serving stale success.
*/
import * as fs from 'fs';
@@ -53,7 +52,11 @@ export interface CodexAuthProfilesSummary {
// ── Cache ───────────────────────────────────────────────────────────────────
let cache: { value: CodexAuthProfilesSummary; expiresAt: number } | null = null;
let cache: {
value: CodexAuthProfilesSummary;
expiresAt: number;
registrySignature: string;
} | null = null;
const TTL_MS = 5000;
/**
@@ -67,6 +70,21 @@ export function invalidateCodexAuthProfilesCache(): void {
// ── Registry helpers ────────────────────────────────────────────────────────
function getRegistryCacheSignature(): string {
const registryPath = getCodexAuthRegistryPath();
try {
const stat = fs.statSync(registryPath);
return ['present', stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':');
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
return 'missing';
}
const msg = err instanceof Error ? err.message : String(err);
logger.warn('codex-auth.dashboard.registry-stat-failed', `Registry stat failed: ${msg}`);
throw new Error('Codex auth profile registry could not be checked safely');
}
}
function readRegistry(): CodexProfileData {
const registryPath = getCodexAuthRegistryPath();
if (!fs.existsSync(registryPath)) {
@@ -225,10 +243,11 @@ async function buildSummary(): Promise<CodexAuthProfilesSummary> {
*/
export async function getCodexAuthProfilesSummary(): Promise<CodexAuthProfilesSummary> {
const now = Date.now();
if (cache && cache.expiresAt > now) {
const registrySignature = getRegistryCacheSignature();
if (cache && cache.expiresAt > now && cache.registrySignature === registrySignature) {
return cache.value;
}
const value = await buildSummary();
cache = { value, expiresAt: now + TTL_MS };
cache = { value, expiresAt: now + TTL_MS, registrySignature };
return value;
}
+6 -3
View File
@@ -24,7 +24,7 @@ export class CodexProfileRegistryReadError extends Error {
}
}
function validateRegistryData(parsed: unknown): CodexProfileData {
export function validateCodexProfileRegistryData(parsed: unknown): CodexProfileData {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('registry YAML root is not an object');
}
@@ -164,7 +164,7 @@ export class CodexProfileRegistry {
}
try {
const raw = fs.readFileSync(this.registryPath, 'utf8');
return validateRegistryData(yaml.load(raw));
return validateCodexProfileRegistryData(yaml.load(raw));
} catch (err) {
const msg = safeRegistryReadMessage(err);
const displayPath = registryDisplayPath(this.registryPath);
@@ -309,13 +309,16 @@ export class CodexProfileRegistry {
});
}
removeProfile(name: string): void {
removeProfile(name: string, options: { forceDefault?: boolean } = {}): void {
assertValidProfileName(name);
this._withRegistryWriteLock(() => {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
if (data.default === name && Object.keys(data.profiles).length > 1 && !options.forceDefault) {
throw new Error('Cannot remove default profile while other profiles exist without --force');
}
delete data.profiles[name];
if (data.default === name) {
data.default = null;
+11 -2
View File
@@ -90,7 +90,16 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
// Ghost case: dir already gone
if (!dirExists) {
process.stderr.write(`[!] Profile dir was already missing; removing registry entry only.\n`);
registry.removeProfile(profileName);
try {
registry.removeProfile(profileName, { forceDefault: force });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile registry update failed; profile dir was already missing.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
console.log(ok(`Profile removed: ${profileName}`));
return;
}
@@ -146,7 +155,7 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
}
try {
registry.removeProfile(profileName);
registry.removeProfile(profileName, { forceDefault: force });
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
+18 -15
View File
@@ -9,6 +9,8 @@ import * as yaml from 'js-yaml';
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
import { getCodexProfileNameError } from './types';
import { validateCodexProfileRegistryData } from './codex-profile-registry';
import type { CodexProfileData } from './types';
export interface ResolvedProfile {
name: string;
@@ -23,12 +25,6 @@ export class CodexAuthProfileResolutionError extends Error {
}
}
interface RegistryShape {
version?: string;
default?: string | null;
profiles?: Record<string, unknown>;
}
function quoteDiagnosticValue(value: string): string {
const escaped = value
.replace(/[\x00-\x1f\x7f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
@@ -117,21 +113,28 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
return null;
}
let registry: RegistryShape;
let parsed: unknown;
try {
const raw = fs.readFileSync(registryPath, 'utf8');
const parsed = yaml.load(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const msg = `registry at ${displayRegistryPath} is not a valid YAML object`;
resolutionFailure(msg, envName, displayEnvName);
}
registry = parsed as RegistryShape;
parsed = yaml.load(raw);
} catch (err) {
if (err instanceof CodexAuthProfileResolutionError) throw err;
const msg = `registry YAML could not be parsed at ${displayRegistryPath}`;
resolutionFailure(msg, envName, displayEnvName);
}
let registry: CodexProfileData;
try {
registry = validateCodexProfileRegistryData(parsed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
resolutionFailure(
`registry at ${displayRegistryPath} is invalid: ${msg}`,
envName,
displayEnvName
);
}
const profiles = registry.profiles;
if (!profiles || typeof profiles !== 'object' || Array.isArray(profiles)) {
resolutionFailure(
@@ -161,8 +164,8 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
}
// F3: registry default
const defaultName = registry.default ?? null;
if (defaultName) {
const defaultName = registry.default;
if (defaultName !== null) {
if (typeof defaultName !== 'string') {
resolutionFailure(
`registry default at ${displayRegistryPath} is not a valid profile name`,
@@ -8,7 +8,7 @@
* - response contains no token substrings
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
@@ -93,6 +93,7 @@ afterEach(async () => {
delete process.env.CCS_HOME;
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PROFILE;
mock.restore();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -123,6 +124,43 @@ describe('GET /api/codex/profiles', () => {
expect((body as { error?: string }).error).toContain('could not be read safely');
});
it('returns 500 when a malformed registry appears after an empty response was cached', async () => {
const first = await get('/api/codex/profiles');
expect(first.status).toBe(200);
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
const future = new Date(Date.now() + 10_000);
fs.utimesSync(registryPath, future, future);
const { status, body } = await get('/api/codex/profiles');
expect(status).toBe(500);
expect((body as { error?: string }).error).toContain('could not be read safely');
});
it('returns a sanitized 500 when registry stat fails', async () => {
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
const rawMessage = `EACCES: permission denied, stat '${registryPath}'`;
const realStatSync = fs.statSync;
spyOn(fs, 'statSync').mockImplementation((target) => {
if (target === registryPath) {
const err = new Error(rawMessage) as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
}
return realStatSync(target);
});
const { status, body } = await get('/api/codex/profiles');
const error = (body as { error?: string }).error ?? '';
expect(status).toBe(500);
expect(error).toContain('could not be checked safely');
expect(error).not.toContain(registryPath);
expect(error).not.toContain('EACCES');
});
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');
+1 -1
View File
@@ -249,7 +249,7 @@ describe('codex-runtime router — non-auth profile resolution', () => {
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('not a valid YAML object');
expect(stderr).toContain('registry YAML root is not an object');
});
it('fails fast for structural resolver errors with the expected name', async () => {
@@ -6,7 +6,7 @@
* and token redaction from response.
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -71,6 +71,11 @@ function writeRegistry(registryPath: string, data: unknown): void {
fs.writeFileSync(registryPath, yaml(data), { mode: 0o600 });
}
function bumpRegistryMtime(registryPath: string): void {
const future = new Date(Date.now() + 10_000);
fs.utimesSync(registryPath, future, future);
}
// Module cache helpers -----------------------------------------------------
async function importService() {
@@ -100,6 +105,7 @@ afterEach(() => {
delete process.env.CCS_HOME;
delete process.env.CODEX_HOME;
delete process.env.CCS_CODEX_PROFILE;
mock.restore();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -125,6 +131,34 @@ describe('getCodexAuthProfilesSummary', () => {
await expect(getCodexAuthProfilesSummary()).rejects.toThrow(/could not be read safely/i);
});
it('does not expose raw stat errors when the registry cannot be checked', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
const rawMessage = `EACCES: permission denied, stat '${registryPath}'`;
const realStatSync = fs.statSync;
spyOn(fs, 'statSync').mockImplementation((target) => {
if (target === registryPath) {
const err = new Error(rawMessage) as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
}
return realStatSync(target);
});
let message = '';
try {
await getCodexAuthProfilesSummary();
} catch (err) {
message = String(err);
}
expect(message).toContain('could not be checked safely');
expect(message).not.toContain(registryPath);
expect(message).not.toContain('EACCES');
});
it('returns decoded email, plan, accountId for valid registry with 2 profiles', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
@@ -357,7 +391,7 @@ describe('getCodexAuthProfilesSummary', () => {
expect(result.active).toBeNull();
});
it('returns cached value on second call within 5s (no extra fs reads)', async () => {
it('returns cached value on second call within 5s when the registry file is unchanged', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
@@ -373,14 +407,42 @@ describe('getCodexAuthProfilesSummary', () => {
);
const first = await getCodexAuthProfilesSummary();
// Modify registry after first call — should NOT be seen within cache TTL
const second = await getCodexAuthProfilesSummary();
// Both calls should return same reference (cache hit)
expect(second).toBe(first);
});
it('does not serve cached missing-registry success after a malformed registry appears', async () => {
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
invalidateCodexAuthProfilesCache();
const first = await getCodexAuthProfilesSummary();
expect(first.profiles).toHaveLength(0);
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
bumpRegistryMtime(registryPath);
await expect(getCodexAuthProfilesSummary()).rejects.toThrow(/could not be read safely/i);
});
it('does not serve cached valid-registry success after the registry becomes malformed', 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 second = await getCodexAuthProfilesSummary();
// Both calls should return same reference (cache hit)
expect(second).toBe(first);
const first = await getCodexAuthProfilesSummary();
expect(first.profiles).toHaveLength(0);
fs.writeFileSync(registryPath, '{ invalid: yaml: [', { mode: 0o600 });
bumpRegistryMtime(registryPath);
await expect(getCodexAuthProfilesSummary()).rejects.toThrow(/could not be read safely/i);
});
it('invalidateCodexAuthProfilesCache forces re-read on next call', async () => {
@@ -10,7 +10,7 @@ let CodexProfileRegistry: new (registryPath?: string) => {
createProfile(name: string, meta?: Record<string, unknown>): void;
getProfile(name: string): Record<string, unknown>;
updateProfile(name: string, partial: Record<string, unknown>): void;
removeProfile(name: string): void;
removeProfile(name: string, options?: { forceDefault?: boolean }): void;
listProfiles(): string[];
hasProfile(name: string): boolean;
getDefault(): string | null;
@@ -128,13 +128,25 @@ describe('CodexProfileRegistry — remove', () => {
expect(reg.getDefault()).toBeNull();
});
it('does not promote another profile when the default profile is removed', () => {
it('refuses to remove the default profile when other profiles remain without force', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.createProfile('personal');
reg.setDefault('work');
reg.removeProfile('work');
expect(() => reg.removeProfile('work')).toThrow(/default profile.*without --force/i);
expect(reg.listProfiles()).toEqual(['work', 'personal']);
expect(reg.getDefault()).toBe('work');
});
it('does not promote another profile when the default profile is force removed', () => {
const reg = new CodexProfileRegistry(registryPath);
reg.createProfile('work');
reg.createProfile('personal');
reg.setDefault('work');
reg.removeProfile('work', { forceDefault: true });
expect(reg.listProfiles()).toEqual(['personal']);
expect(reg.getDefault()).toBeNull();
@@ -125,6 +125,49 @@ describe('handleRemoveCodex — default guard', () => {
await handleRemoveCodex(ctx, ['alpha', '--force', '--yes']);
expect(ctx.registry.hasProfile('alpha')).toBe(false);
});
it('restores data when the target becomes default between precheck and registry write', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('alpha', 'beta');
ctx.registry.setDefault('beta');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'alpha');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realCpSync = fs.cpSync;
spyOn(fs, 'cpSync').mockImplementation((src, dest, options) => {
const result = realCpSync(src, dest, options);
if (typeof dest === 'string' && dest.includes('.preserved.')) {
ctx.registry.setDefault('alpha');
}
return result;
});
let exitCode = -1;
const origExit = process.exit;
const origErr = console.error;
process.exit = (code?: number) => {
exitCode = code ?? 0;
throw new Error('exit');
};
console.error = () => {};
try {
await handleRemoveCodex(ctx, ['alpha', '--yes']);
} catch {
/* process.exit */
} finally {
process.exit = origExit;
console.error = origErr;
}
expect(exitCode).toBeGreaterThan(0);
expect(fs.existsSync(authJsonPath)).toBe(true);
expect(ctx.registry.hasProfile('alpha')).toBe(true);
expect(ctx.registry.getDefault()).toBe('alpha');
});
});
// ── only profile → allows removal ────────────────────────────────────────────
@@ -95,7 +95,17 @@ describe('resolveActiveProfile', () => {
mode: 0o600,
});
expect(() => resolveActiveProfile({})).toThrow(/valid profiles map/);
expect(() => resolveActiveProfile({})).toThrow(/object profiles map/);
});
it('throws instead of falling back when registry default has an invalid falsy type', () => {
writeRegistry({
version: '1.0',
default: false,
profiles: {},
});
expect(() => resolveActiveProfile({})).toThrow(/default must be a string or null/i);
});
it('throws when CCS_CODEX_PROFILE contains an unsafe profile name', () => {
@@ -129,7 +139,7 @@ describe('resolveActiveProfile', () => {
profiles: {},
});
expect(() => resolveActiveProfile({})).toThrow(/default 'ghost' is missing/i);
expect(() => resolveActiveProfile({})).toThrow(/default profile is missing from profiles map/i);
});
it('throws when matched registry profile entry is malformed', () => {
@@ -141,7 +151,36 @@ describe('resolveActiveProfile', () => {
},
});
expect(() => resolveActiveProfile({})).toThrow(/not a valid object/i);
expect(() => resolveActiveProfile({})).toThrow(/profile "work" must be an object/i);
});
it('throws when a registry profile is missing required metadata', () => {
writeRegistry({
version: '1.0',
default: 'work',
profiles: {
work: { type: 'codex', last_used: null },
},
});
expect(() => resolveActiveProfile({})).toThrow(/created timestamp/i);
});
it('throws when a registry profile has invalid optional metadata types', () => {
writeRegistry({
version: '1.0',
default: 'work',
profiles: {
work: {
type: 'codex',
created: '2026-01-01T00:00:00.000Z',
last_used: null,
account_id: 123,
},
},
});
expect(() => resolveActiveProfile({})).toThrow(/account_id must be a string/i);
});
it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => {