fix(codex-auth): close remaining review gaps

This commit is contained in:
Tam Nhu Tran committed 2026-05-17 16:51:22 -04:00
1 parent 08fe63c626
commit 5c79df4311
20 files changed
+823 -93

No files matched your search

+20 -2
View File
@@ -17,6 +17,24 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
function isCodexAuthProfileResolutionError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'name' in err &&
(err as { name?: unknown }).name === 'CodexAuthProfileResolutionError'
);
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === 'object' && err !== null && 'message' in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === 'string') return message;
}
return String(err);
}
/**
* Main entry-point for the ccsx / codex-runtime binary.
*
@@ -62,8 +80,8 @@ export async function main(argv: string[]): Promise<number> {
}
}
} catch (resolverErr) {
const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr);
if (resolverErr instanceof Error && resolverErr.name === 'CodexAuthProfileResolutionError') {
const msg = errorMessage(resolverErr);
if (isCodexAuthProfileResolutionError(resolverErr)) {
process.stderr.write(`[X] codex-auth: ${msg}\n`);
return 1;
}
@@ -38,7 +38,7 @@ function sleep(ms: number): Promise<void> {
}
/**
* Detect a running `codex` process via pgrep (best-effort, never throws).
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
* Returns the PID string if found, null otherwise.
*/
function detectCodexRunning(): string | null {
@@ -47,15 +47,83 @@ function detectCodexRunning(): string | null {
encoding: 'utf8',
timeout: 2000,
});
if (result.status === 0 && result.stdout && result.stdout.trim().length > 0) {
return result.stdout.trim().split('\n')[0];
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return null;
}
return null;
const pids = parsePgrepPids(result.stdout);
if (pids.length === 0) return null;
const psResult = childProcess.spawnSync(
'ps',
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
{
encoding: 'utf8',
timeout: 2000,
}
);
if (psResult.status !== 0 || !psResult.stdout) return null;
return selectCodexPidFromPsOutput(psResult.stdout, pids);
} catch {
return null;
}
}
function parsePgrepPids(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
}
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
const candidates = new Set(candidatePids);
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
if (!match) continue;
const [, pid, command] = match;
if (!pid || !command || !candidates.has(pid)) continue;
if (isLikelyCodexProcessCommand(command)) return pid;
}
return null;
}
function isLikelyCodexProcessCommand(command: string): boolean {
const executable = firstCommandToken(command);
if (isCodexExecutableToken(executable)) {
return true;
}
const normalized = command.replace(/\\/g, '/').toLowerCase();
if (normalized.includes('/@openai/codex/')) {
return true;
}
return command.split(/\s+/).some((token) => {
const tokenName = executableTokenBasename(token);
return tokenName === 'codex.js' || isCodexExecutableName(tokenName);
});
}
function firstCommandToken(command: string): string {
const trimmed = command.trim();
const quoted = trimmed.match(/^"([^"]+)"/);
if (quoted?.[1]) return quoted[1];
return trimmed.split(/\s+/)[0] ?? '';
}
function isCodexExecutableToken(token: string): boolean {
return isCodexExecutableName(executableTokenBasename(token));
}
function executableTokenBasename(token: string): string {
return path.basename(token.replace(/^["']|["']$/g, '').replace(/\\/g, '/')).toLowerCase();
}
function isCodexExecutableName(name: string): boolean {
return ['codex', 'codex.exe', 'codex.cmd', 'codex.ps1'].includes(name);
}
/**
* Read and validate auth.json with retry on torn-write (C3).
* Returns parsed auth JSON or throws on persistent failure.
+32 -1
View File
@@ -113,6 +113,9 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
const preservationDir = `${profileDir}.preserved.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
try {
fs.renameSync(profileDir, stagedDeleteDir);
@@ -125,10 +128,25 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
throw err;
}
try {
fs.cpSync(stagedDeleteDir, preservationDir, { recursive: true, errorOnExist: true });
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
`Profile data delete preparation failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
try {
registry.removeProfile(profileName);
} catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
exitWithError(
@@ -140,9 +158,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
try {
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
fs.rmSync(preservationDir, { recursive: true, force: true });
} catch (err) {
const restoredDir = _restoreProfileDir(stagedDeleteDir, profileDir);
const restoreSource = fs.existsSync(preservationDir) ? preservationDir : stagedDeleteDir;
const restoredDir = _restoreProfileDir(restoreSource, profileDir);
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
if (restoredDir && restoreSource === preservationDir) {
_removePathBestEffort(stagedDeleteDir);
}
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err);
const registryNote = restoredRegistry
@@ -158,6 +181,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
console.log(ok(`Profile removed: ${profileName}`));
}
function _removePathBestEffort(targetPath: string): void {
try {
fs.rmSync(targetPath, { recursive: true, force: true });
} catch {
// best-effort cleanup after data has already been preserved elsewhere
}
}
function _restoreRegistryEntry(
registry: CodexCommandContext['registry'],
profileName: string,
+11 -7
View File
@@ -14,6 +14,7 @@ import { decodeAccountIdentity } from '../codex-account-identity';
import { showProfileDetail } from './show-detail-view';
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
import type { CodexCommandContext, CodexProfileOutput } from './types';
import type { CodexAccountIdentity } from '../types';
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
@@ -44,6 +45,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
name: string;
email: string;
plan: string;
accountId: string | null;
lastUsed: string;
state: string;
missing?: boolean;
@@ -57,6 +59,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
name: activeName ?? '',
email: '<unknown>',
plan: '-',
accountId: null,
lastUsed: 'never',
state: 'active(missing)',
missing: true,
@@ -71,15 +74,16 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
const profileDir = resolveCodexProfileDir(name);
const authJsonPath = path.join(profileDir, 'auth.json');
let email = meta.email ?? '<unknown>';
if (fs.existsSync(authJsonPath) && !meta.email) {
const identity = decodeAccountIdentity(authJsonPath);
email = identity.email ?? '<unknown>';
}
const identity: CodexAccountIdentity = fs.existsSync(authJsonPath)
? decodeAccountIdentity(authJsonPath)
: {};
const email = meta.email ?? identity.email ?? '<unknown>';
const plan = meta.plan_type ?? identity.plan_type ?? '-';
const accountId = meta.account_id ?? identity.account_id ?? null;
const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never';
rows.push({ name, email, plan: meta.plan_type ?? '-', lastUsed, state: states.join(',') });
rows.push({ name, email, plan, accountId, lastUsed, state: states.join(',') });
}
if (json) {
@@ -94,7 +98,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
last_used: meta?.last_used ?? null,
email: r.email === '<unknown>' ? null : r.email,
plan: r.plan === '-' ? null : r.plan,
account_id: null,
account_id: r.accountId,
profile_dir: profileDir,
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
auth_json_mtime: null,
+3 -2
View File
@@ -68,6 +68,7 @@ export function showProfileDetail(
if (isDefault) states.push('default');
if (isActive) states.push('active');
const stateStr = states.join(',');
const accountId = meta.account_id ?? identity.account_id ?? null;
if (json) {
const out: CodexProfileOutput = {
@@ -78,7 +79,7 @@ export function showProfileDetail(
last_used: meta.last_used ?? null,
email: identity.email ?? null,
plan: meta.plan_type ?? null,
account_id: identity.account_id ?? null,
account_id: accountId,
profile_dir: profileDir,
auth_json_exists: authExists,
auth_json_mtime: authMtime,
@@ -99,7 +100,7 @@ export function showProfileDetail(
['auth.json', authState],
['Email', identity.email ?? (authExists ? '<invalid>' : '<unknown>')],
['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')],
['Account ID', identity.account_id ?? '-'],
['Account ID', accountId ?? '-'],
['Created', new Date(meta.created).toLocaleString()],
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'],
+1 -1
View File
@@ -31,7 +31,7 @@ function base64urlDecode(str: string): string {
}
function isBase64UrlSegment(str: string): boolean {
return str.length > 0 && BASE64URL_SEGMENT_RE.test(str);
return str.length > 0 && str.length % 4 !== 1 && BASE64URL_SEGMENT_RE.test(str);
}
function decodeJsonSegment(str: string): unknown {
+33 -9
View File
@@ -7,6 +7,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
export interface ResolvedProfile {
name: string;
@@ -27,16 +28,41 @@ interface RegistryShape {
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')}`)
.replace(/'/g, "\\'");
return `'${escaped.length > 96 ? `${escaped.slice(0, 96)}...` : escaped}'`;
}
function registryDisplayPath(registryPath: string): string {
const [source] = getCcsDirSource();
if (source === 'default') {
return process.platform === 'win32'
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
: '~/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
return '$CCS_HOME/.ccs/codex-profiles.yaml';
}
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
return '$CCS_DIR/codex-profiles.yaml';
}
return registryPath;
}
/** @param env - Process env map; defaults to process.env. Injectable for tests. */
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
const registryPath = getCodexAuthRegistryPath();
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
const displayEnvName = quoteDiagnosticValue(envName);
const displayRegistryPath = registryDisplayPath(registryPath);
// F4: silent fallback — no registry means no profiles, legacy mode
if (!fs.existsSync(registryPath)) {
if (envName) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE='${envName}' is set but ${registryPath} does not exist. Refusing to fall back to ~/.codex.`
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${displayRegistryPath} does not exist. Refusing to fall back to ~/.codex.`
);
}
return null;
@@ -47,10 +73,10 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
const raw = fs.readFileSync(registryPath, 'utf8');
const parsed = yaml.load(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const msg = `registry at ${registryPath} is not a valid YAML object`;
const msg = `registry at ${displayRegistryPath} is not a valid YAML object`;
if (envName) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE='${envName}' is set but ${msg}. Refusing to fall back to ~/.codex.`
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${msg}. Refusing to fall back to ~/.codex.`
);
}
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
@@ -59,15 +85,13 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
registry = parsed as RegistryShape;
} catch (err) {
if (err instanceof CodexAuthProfileResolutionError) throw err;
const msg = err instanceof Error ? err.message : String(err);
const msg = `registry YAML could not be parsed at ${displayRegistryPath}`;
if (envName) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE='${envName}' is set but registry YAML is corrupt at ${registryPath} (${msg}). Refusing to fall back to ~/.codex.`
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${msg}. Refusing to fall back to ~/.codex.`
);
}
process.stderr.write(
`[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n`
);
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
return null;
}
@@ -77,7 +101,7 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
if (envName) {
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
throw new CodexAuthProfileResolutionError(
`CCS_CODEX_PROFILE='${envName}' not found in registry. Refusing to fall back to ~/.codex.`
`CCS_CODEX_PROFILE=${displayEnvName} not found in registry. Refusing to fall back to ~/.codex.`
);
}
return {
+59 -7
View File
@@ -3,19 +3,27 @@
* Determines current shell to emit correct eval-safe export syntax.
*/
import * as childProcess from 'child_process';
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
/**
* Detect current shell from environment.
* On Windows: PSModulePath presence → pwsh, else cmd.
* On Windows: inspect explicit shell executable hints, else default to cmd.
* On Unix: inspect $SHELL suffix.
*/
export function detectShell(
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform
platform: string = process.platform,
parentProcessName?: string
): Shell {
if (platform === 'win32') {
return env.PSModulePath ? 'pwsh' : 'cmd';
return (
shellFromExecutable(env.SHELL) ??
shellFromExecutable(parentProcessName ?? detectParentProcessName(platform)) ??
shellFromExecutable(env.ComSpec ?? env.COMSPEC) ??
'cmd'
);
}
const sh = (env.SHELL ?? '').toLowerCase();
if (sh.endsWith('/fish')) return 'fish';
@@ -23,6 +31,50 @@ export function detectShell(
return 'bash'; // default for bash, sh, dash, ksh
}
function detectParentProcessName(platform: string): string | undefined {
if (platform !== 'win32' || !Number.isInteger(process.ppid) || process.ppid <= 0) {
return undefined;
}
const result = childProcess.spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Process -Id ${process.ppid} -ErrorAction Stop).ProcessName`,
],
{ encoding: 'utf8', timeout: 1500, windowsHide: true }
);
if (result.status !== 0 || !result.stdout) return undefined;
return result.stdout.trim().split(/\r?\n/).pop()?.trim();
}
function shellFromExecutable(value: string | undefined): Shell | null {
if (!value) return null;
const base = value
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.split('/')
.pop()
?.toLowerCase()
.replace(/\.(exe|cmd|ps1|bat)$/i, '');
switch (base) {
case 'fish':
case 'zsh':
case 'bash':
case 'cmd':
return base;
case 'pwsh':
case 'powershell':
return 'pwsh';
default:
return null;
}
}
/**
* Single-quote escape for POSIX shells (bash/zsh/fish).
* Closes the single-quote, inserts escaped quote, reopens.
@@ -33,11 +85,11 @@ function posixSingleQuote(value: string): string {
/**
* Double-quote escape for PowerShell.
* Wraps in double quotes; escapes internal double quotes by doubling them
* and backtick-escapes $ to prevent variable interpolation.
* Wraps in double quotes; escapes the PowerShell escape char first, doubles
* internal double quotes, and backtick-escapes $ to prevent interpolation.
*/
function pwshDoubleQuote(value: string): string {
return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"';
return '"' + value.replace(/`/g, '``').replace(/"/g, '""').replace(/\$/g, '`$') + '"';
}
/**
@@ -45,7 +97,7 @@ function pwshDoubleQuote(value: string): string {
* like &, |, <, and > inside the assignment instead of executing them.
*/
function cmdSetQuote(value: string): string {
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"');
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"').replace(/!/g, '^^!');
}
/**