fix(codex-auth): close remaining review gaps

This commit is contained in:
Tam Nhu Tran
2026-05-17 16:51:22 -04:00
parent 08fe63c626
commit 5c79df4311
20 changed files with 823 additions and 93 deletions
+20 -2
View File
@@ -17,6 +17,24 @@
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; 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. * Main entry-point for the ccsx / codex-runtime binary.
* *
@@ -62,8 +80,8 @@ export async function main(argv: string[]): Promise<number> {
} }
} }
} catch (resolverErr) { } catch (resolverErr) {
const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr); const msg = errorMessage(resolverErr);
if (resolverErr instanceof Error && resolverErr.name === 'CodexAuthProfileResolutionError') { if (isCodexAuthProfileResolutionError(resolverErr)) {
process.stderr.write(`[X] codex-auth: ${msg}\n`); process.stderr.write(`[X] codex-auth: ${msg}\n`);
return 1; 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. * Returns the PID string if found, null otherwise.
*/ */
function detectCodexRunning(): string | null { function detectCodexRunning(): string | null {
@@ -47,15 +47,83 @@ function detectCodexRunning(): string | null {
encoding: 'utf8', encoding: 'utf8',
timeout: 2000, timeout: 2000,
}); });
if (result.status === 0 && result.stdout && result.stdout.trim().length > 0) { if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
return result.stdout.trim().split('\n')[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 { } catch {
return null; 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). * Read and validate auth.json with retry on torn-write (C3).
* Returns parsed auth JSON or throws on persistent failure. * 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() const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
.toString(36) .toString(36)
.slice(2)}`; .slice(2)}`;
const preservationDir = `${profileDir}.preserved.${process.pid}.${Math.random()
.toString(36)
.slice(2)}`;
try { try {
fs.renameSync(profileDir, stagedDeleteDir); fs.renameSync(profileDir, stagedDeleteDir);
@@ -125,10 +128,25 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
throw err; 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 { try {
registry.removeProfile(profileName); registry.removeProfile(profileName);
} catch (err) { } catch (err) {
const restored = _restoreProfileDir(stagedDeleteDir, profileDir); const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
_removePathBestEffort(preservationDir);
const preservedPath = restored ? profileDir : stagedDeleteDir; const preservedPath = restored ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
exitWithError( exitWithError(
@@ -140,9 +158,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
try { try {
fs.rmSync(stagedDeleteDir, { recursive: true, force: true }); fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
fs.rmSync(preservationDir, { recursive: true, force: true });
} catch (err) { } 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); const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
if (restoredDir && restoreSource === preservationDir) {
_removePathBestEffort(stagedDeleteDir);
}
const preservedPath = restoredDir ? profileDir : stagedDeleteDir; const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
const registryNote = restoredRegistry const registryNote = restoredRegistry
@@ -158,6 +181,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
console.log(ok(`Profile removed: ${profileName}`)); 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( function _restoreRegistryEntry(
registry: CodexCommandContext['registry'], registry: CodexCommandContext['registry'],
profileName: string, profileName: string,
+11 -7
View File
@@ -14,6 +14,7 @@ import { decodeAccountIdentity } from '../codex-account-identity';
import { showProfileDetail } from './show-detail-view'; import { showProfileDetail } from './show-detail-view';
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types'; import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
import type { CodexCommandContext, CodexProfileOutput } from './types'; import type { CodexCommandContext, CodexProfileOutput } from './types';
import type { CodexAccountIdentity } from '../types';
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> { export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI(); await initUI();
@@ -44,6 +45,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
name: string; name: string;
email: string; email: string;
plan: string; plan: string;
accountId: string | null;
lastUsed: string; lastUsed: string;
state: string; state: string;
missing?: boolean; missing?: boolean;
@@ -57,6 +59,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
name: activeName ?? '', name: activeName ?? '',
email: '<unknown>', email: '<unknown>',
plan: '-', plan: '-',
accountId: null,
lastUsed: 'never', lastUsed: 'never',
state: 'active(missing)', state: 'active(missing)',
missing: true, missing: true,
@@ -71,15 +74,16 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
const profileDir = resolveCodexProfileDir(name); const profileDir = resolveCodexProfileDir(name);
const authJsonPath = path.join(profileDir, 'auth.json'); const authJsonPath = path.join(profileDir, 'auth.json');
let email = meta.email ?? '<unknown>'; const identity: CodexAccountIdentity = fs.existsSync(authJsonPath)
if (fs.existsSync(authJsonPath) && !meta.email) { ? decodeAccountIdentity(authJsonPath)
const identity = decodeAccountIdentity(authJsonPath); : {};
email = identity.email ?? '<unknown>'; 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'; 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) { if (json) {
@@ -94,7 +98,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
last_used: meta?.last_used ?? null, last_used: meta?.last_used ?? null,
email: r.email === '<unknown>' ? null : r.email, email: r.email === '<unknown>' ? null : r.email,
plan: r.plan === '-' ? null : r.plan, plan: r.plan === '-' ? null : r.plan,
account_id: null, account_id: r.accountId,
profile_dir: profileDir, profile_dir: profileDir,
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')), auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
auth_json_mtime: null, auth_json_mtime: null,
+3 -2
View File
@@ -68,6 +68,7 @@ export function showProfileDetail(
if (isDefault) states.push('default'); if (isDefault) states.push('default');
if (isActive) states.push('active'); if (isActive) states.push('active');
const stateStr = states.join(','); const stateStr = states.join(',');
const accountId = meta.account_id ?? identity.account_id ?? null;
if (json) { if (json) {
const out: CodexProfileOutput = { const out: CodexProfileOutput = {
@@ -78,7 +79,7 @@ export function showProfileDetail(
last_used: meta.last_used ?? null, last_used: meta.last_used ?? null,
email: identity.email ?? null, email: identity.email ?? null,
plan: meta.plan_type ?? null, plan: meta.plan_type ?? null,
account_id: identity.account_id ?? null, account_id: accountId,
profile_dir: profileDir, profile_dir: profileDir,
auth_json_exists: authExists, auth_json_exists: authExists,
auth_json_mtime: authMtime, auth_json_mtime: authMtime,
@@ -99,7 +100,7 @@ export function showProfileDetail(
['auth.json', authState], ['auth.json', authState],
['Email', identity.email ?? (authExists ? '<invalid>' : '<unknown>')], ['Email', identity.email ?? (authExists ? '<invalid>' : '<unknown>')],
['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')], ['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')],
['Account ID', identity.account_id ?? '-'], ['Account ID', accountId ?? '-'],
['Created', new Date(meta.created).toLocaleString()], ['Created', new Date(meta.created).toLocaleString()],
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'], ['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'], ['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 { 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 { 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 path from 'path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths'; import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
import { getCcsDirSource } from '../utils/config-manager';
export interface ResolvedProfile { export interface ResolvedProfile {
name: string; name: string;
@@ -27,16 +28,41 @@ interface RegistryShape {
profiles?: Record<string, unknown>; 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. */ /** @param env - Process env map; defaults to process.env. Injectable for tests. */
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null { export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
const registryPath = getCodexAuthRegistryPath(); const registryPath = getCodexAuthRegistryPath();
const envName = (env.CCS_CODEX_PROFILE ?? '').trim(); 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 // F4: silent fallback — no registry means no profiles, legacy mode
if (!fs.existsSync(registryPath)) { if (!fs.existsSync(registryPath)) {
if (envName) { if (envName) {
throw new CodexAuthProfileResolutionError( 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; return null;
@@ -47,10 +73,10 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
const raw = fs.readFileSync(registryPath, 'utf8'); const raw = fs.readFileSync(registryPath, 'utf8');
const parsed = yaml.load(raw); const parsed = yaml.load(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { 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) { if (envName) {
throw new CodexAuthProfileResolutionError( 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`); 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; registry = parsed as RegistryShape;
} catch (err) { } catch (err) {
if (err instanceof CodexAuthProfileResolutionError) throw 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) { if (envName) {
throw new CodexAuthProfileResolutionError( 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( process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
`[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n`
);
return null; return null;
} }
@@ -77,7 +101,7 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
if (envName) { if (envName) {
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) { if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
throw new CodexAuthProfileResolutionError( 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 { return {
+59 -7
View File
@@ -3,19 +3,27 @@
* Determines current shell to emit correct eval-safe export syntax. * Determines current shell to emit correct eval-safe export syntax.
*/ */
import * as childProcess from 'child_process';
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd'; export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
/** /**
* Detect current shell from environment. * 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. * On Unix: inspect $SHELL suffix.
*/ */
export function detectShell( export function detectShell(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform platform: string = process.platform,
parentProcessName?: string
): Shell { ): Shell {
if (platform === 'win32') { 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(); const sh = (env.SHELL ?? '').toLowerCase();
if (sh.endsWith('/fish')) return 'fish'; if (sh.endsWith('/fish')) return 'fish';
@@ -23,6 +31,50 @@ export function detectShell(
return 'bash'; // default for bash, sh, dash, ksh 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). * Single-quote escape for POSIX shells (bash/zsh/fish).
* Closes the single-quote, inserts escaped quote, reopens. * Closes the single-quote, inserts escaped quote, reopens.
@@ -33,11 +85,11 @@ function posixSingleQuote(value: string): string {
/** /**
* Double-quote escape for PowerShell. * Double-quote escape for PowerShell.
* Wraps in double quotes; escapes internal double quotes by doubling them * Wraps in double quotes; escapes the PowerShell escape char first, doubles
* and backtick-escapes $ to prevent variable interpolation. * internal double quotes, and backtick-escapes $ to prevent interpolation.
*/ */
function pwshDoubleQuote(value: string): string { 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. * like &, |, <, and > inside the assignment instead of executing them.
*/ */
function cmdSetQuote(value: string): string { function cmdSetQuote(value: string): string {
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"'); return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"').replace(/!/g, '^^!');
} }
/** /**
+97 -15
View File
@@ -45,6 +45,20 @@ function makeProfileDir(name: string): string {
return dir; return dir;
} }
async function withCapturedStderr<T>(fn: () => Promise<T>): Promise<{ result: T; stderr: string }> {
const stderrMessages: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array): boolean => {
stderrMessages.push(typeof chunk === 'string' ? chunk : String(chunk));
return true;
};
try {
return { result: await fn(), stderr: stderrMessages.join('') };
} finally {
process.stderr.write = origWrite;
}
}
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-')); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
ccsHome = path.join(tempDir, 'ccs'); ccsHome = path.join(tempDir, 'ccs');
@@ -151,27 +165,95 @@ describe('codex-runtime router — non-auth profile resolution', () => {
}); });
process.env.CCS_CODEX_PROFILE = 'ghost'; process.env.CCS_CODEX_PROFILE = 'ghost';
const stderrMessages: string[] = []; const { result: code, stderr } = await withCapturedStderr(async () => {
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array): boolean => {
stderrMessages.push(typeof chunk === 'string' ? chunk : String(chunk));
return true;
};
try {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module; require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache(); flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module; require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> }; const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
const code = await main(['node', 'codex-runtime', 'chat']); return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1); expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined(); expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderrMessages.join('')).toContain("CCS_CODEX_PROFILE='ghost'"); expect(stderr).toContain("CCS_CODEX_PROFILE='ghost'");
} finally { });
process.stderr.write = origWrite;
} it('fails fast when CCS_CODEX_PROFILE is set but registry is missing', async () => {
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('does not exist');
});
it('fails fast when CCS_CODEX_PROFILE is set and registry YAML is corrupt', async () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'profiles: [unterminated\n');
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('registry YAML could not be parsed');
});
it('fails fast when CCS_CODEX_PROFILE is set and registry is not an object', async () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '- not\n- an\n- object\n');
process.env.CCS_CODEX_PROFILE = 'ghost';
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('not a valid YAML object');
});
it('fails fast for structural resolver errors with the expected name', async () => {
const { result: code, stderr } = await withCapturedStderr(async () => {
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
flushRouterCache();
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
require.cache[resolveProfilePath] = {
exports: {
resolveActiveProfile: () => {
throw { name: 'CodexAuthProfileResolutionError', message: 'boundary failure' };
},
},
} as NodeJS.Module;
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain('boundary failure');
}); });
it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => { it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => {
@@ -123,6 +123,45 @@ function captureOutput(): { stderr: string[]; restore: () => void } {
}; };
} }
function mockProcessTable(pgrepStdout: string, psStdout: string) {
spyOn(childProcess, 'spawnSync').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cmd: string, _args: string[]): any => {
if (cmd === 'pgrep') {
return {
status: pgrepStdout.trim().length > 0 ? 0 : 1,
stdout: pgrepStdout,
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
if (cmd === 'ps') {
return {
status: psStdout.trim().length > 0 ? 0 : 1,
stdout: psStdout,
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
return {
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
};
}
);
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
describe('import-default — missing legacy auth.json', () => { describe('import-default — missing legacy auth.json', () => {
@@ -385,22 +424,43 @@ describe('import-default — torn-write retry', () => {
expect(exitCalled).toBe(true); expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('bad-base64url')).toBe(false); expect(ctx.registry.hasProfile('bad-base64url')).toBe(false);
}); });
it('rejects an id_token signature with impossible base64url length', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
const [header, payload] = VALID_JWT.split('.');
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: `${header}.${payload}.a` } }));
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['bad-signature']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('bad-signature')).toBe(false);
});
}); });
describe('import-default — Codex running detection', () => { describe('import-default — Codex running detection', () => {
it('warns and refuses when pgrep finds a codex PID', async () => { it('warns and refuses when pgrep finds a codex PID', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
// Mock spawnSync to simulate pgrep finding codex mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 0,
stdout: '12345\n',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
const { handleImportDefaultCodex } = await import( const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command' '../../../../src/codex-auth/commands/import-default-command'
@@ -431,15 +491,7 @@ describe('import-default — Codex running detection', () => {
it('proceeds with --force-while-running even when Codex is running', async () => { it('proceeds with --force-while-running even when Codex is running', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
spyOn(childProcess, 'spawnSync').mockReturnValue({ mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
status: 0,
stdout: '12345\n',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
const { handleImportDefaultCodex } = await import( const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command' '../../../../src/codex-auth/commands/import-default-command'
@@ -456,6 +508,57 @@ describe('import-default — Codex running detection', () => {
// Should have proceeded and created the profile // Should have proceeded and created the profile
expect(ctx.registry.hasProfile('forcerunning')).toBe(true); expect(ctx.registry.hasProfile('forcerunning')).toBe(true);
}); });
it('warns and refuses when Codex is running through a node shim', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('12345\n', '12345 /usr/bin/node /usr/local/bin/codex login\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const captured = captureOutput();
try {
await handleImportDefaultCodex(ctx, ['nodeshim']);
} catch {
/* expected */
} finally {
captured.restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(captured.stderr.join('')).toContain('12345');
expect(ctx.registry.hasProfile('nodeshim')).toBe(false);
});
it('ignores pgrep false positives that are not Codex executables', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
mockProcessTable('11111\n', '11111 /usr/bin/node /tmp/codex-auth-helper.js\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['falsepositive']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('falsepositive')).toBe(true);
});
}); });
describe('import-default — --with-history', () => { describe('import-default — --with-history', () => {
@@ -53,6 +53,12 @@ function spawnReturnsCode(code: number, writeAuth = false) {
}); });
} }
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`;
}
describe('handleLoginCodex — binary missing', () => { describe('handleLoginCodex — binary missing', () => {
it('exits with BINARY_ERROR when codex not found', async () => { it('exits with BINARY_ERROR when codex not found', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector'); const detectorMod = await import('../../../../src/targets/codex-detector');
@@ -149,4 +155,57 @@ describe('handleLoginCodex — clean exit updates registry', () => {
// last_used should now be set // last_used should now be set
expect(meta.last_used).toBeTruthy(); expect(meta.last_used).toBeTruthy();
}); });
it('persists account_id when login token has account_id only', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
spyOn(childProcess, 'spawn').mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_cmd: string, _args: string[], opts: any) => {
const dir = (opts?.env?.CODEX_HOME as string) ?? '';
if (dir) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'auth.json'),
JSON.stringify({
tokens: {
id_token: buildToken({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct-login-only',
},
}),
},
})
);
}
const ee = {
on: (evt: string, cb: (code: number) => void) => {
if (evt === 'exit') setTimeout(() => cb(0), 0);
return ee;
},
};
return ee as ReturnType<typeof childProcess.spawn>;
}
);
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
const ctx = await makeCtx();
ctx.registry.createProfile('accountonly', {
created: new Date().toISOString(),
last_used: null,
});
const origLog = console.log;
console.log = () => {};
try {
await handleLoginCodex(ctx, ['accountonly']);
} finally {
console.log = origLog;
}
const meta = ctx.registry.getProfile('accountonly');
expect(meta.last_used).toBeTruthy();
expect(meta.account_id).toBe('acct-login-only');
});
}); });
@@ -228,6 +228,52 @@ describe('handleRemoveCodex — confirmation', () => {
expect(ctx.registry.hasProfile('preserveme')).toBe(true); expect(ctx.registry.hasProfile('preserveme')).toBe(true);
}); });
it('cleans a partial preservation copy when delete preparation fails', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('copyfail');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'copyfail');
const parentDir = path.dirname(profileDir);
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) => {
if (typeof dest === 'string' && dest.includes('.preserved.')) {
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, 'auth.json'), '{}');
throw new Error('copy failed after partial write');
}
return realCpSync(src, dest, options);
});
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, ['copyfail', '--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('copyfail')).toBe(true);
expect(fs.readdirSync(parentDir).some((entry) => entry.startsWith('copyfail.preserved.'))).toBe(
false
);
});
it('restores profile data and registry when final deletion fails', async () => { it('restores profile data and registry when final deletion fails', async () => {
const { handleRemoveCodex } = await import( const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command' '../../../../src/codex-auth/commands/remove-command'
@@ -269,4 +315,45 @@ describe('handleRemoveCodex — confirmation', () => {
expect(ctx.registry.hasProfile('restoreme')).toBe(true); expect(ctx.registry.hasProfile('restoreme')).toBe(true);
expect(ctx.registry.getDefault()).toBe('restoreme'); expect(ctx.registry.getDefault()).toBe('restoreme');
}); });
it('restores from preserved copy when final deletion partially removes auth.json', async () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
);
const ctx = await makeCtx('partialrestore');
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'partialrestore');
const authJsonPath = path.join(profileDir, 'auth.json');
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
const realRmSync = fs.rmSync;
spyOn(fs, 'rmSync').mockImplementation((target, options) => {
if (typeof target === 'string' && target.includes('.deleting.')) {
realRmSync(path.join(target, 'auth.json'), { force: true });
throw new Error('delete failed after auth removal');
}
return realRmSync(target, options);
});
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, ['partialrestore', '--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('partialrestore')).toBe(true);
});
}); });
@@ -56,6 +56,12 @@ async function captureStdout(fn: () => Promise<void>): Promise<string> {
return chunks.join(''); return chunks.join('');
} }
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`;
}
// ── empty list ──────────────────────────────────────────────────────────────── // ── empty list ────────────────────────────────────────────────────────────────
describe('handleShowCodex — empty list', () => { describe('handleShowCodex — empty list', () => {
@@ -82,6 +88,48 @@ describe('handleShowCodex — default marker', () => {
}); });
}); });
// ── JSON account metadata ───────────────────────────────────────────────────
describe('handleShowCodex — JSON account_id', () => {
it('includes account_id from registry metadata or auth.json identity', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('registryid', 'authid');
ctx.registry.updateProfile('registryid', {
account_id: 'acct-from-registry',
email: 'registry@example.com',
});
const authProfileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'authid');
fs.mkdirSync(authProfileDir, { recursive: true });
fs.writeFileSync(
path.join(authProfileDir, 'auth.json'),
JSON.stringify({
tokens: {
id_token: buildToken({
email: 'auth@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-from-auth-json',
},
}),
},
})
);
const out = await captureStdout(() => handleShowCodex(ctx, ['--json']));
const parsed = JSON.parse(out) as {
profiles: Array<{ name: string; account_id: string | null; email: string | null }>;
};
expect(parsed.profiles.find((p) => p.name === 'registryid')?.account_id).toBe(
'acct-from-registry'
);
expect(parsed.profiles.find((p) => p.name === 'authid')?.account_id).toBe(
'acct-from-auth-json'
);
});
});
// ── active(missing) row at top (D14) ───────────────────────────────────────── // ── active(missing) row at top (D14) ─────────────────────────────────────────
describe('handleShowCodex — active(missing) at top', () => { describe('handleShowCodex — active(missing) at top', () => {
@@ -120,6 +168,19 @@ describe('handleShowCodex — detail view', () => {
expect(out).toContain('<unknown>'); expect(out).toContain('<unknown>');
}); });
it('detail JSON includes account_id from registry metadata when auth.json is missing', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('registrydetail');
ctx.registry.updateProfile('registrydetail', {
account_id: 'acct-from-registry-detail',
});
const out = await captureStdout(() => handleShowCodex(ctx, ['registrydetail', '--json']));
const parsed = JSON.parse(out) as { account_id: string | null };
expect(parsed.account_id).toBe('acct-from-registry-detail');
});
it('does not crash with malformed auth.json', async () => { it('does not crash with malformed auth.json', async () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
const ctx = await makeCtx('malformed'); const ctx = await makeCtx('malformed');
@@ -133,4 +133,10 @@ describe('decodeIdToken', () => {
expect(hasStructurallyValidIdToken(`${header}.${payload}$.${signature}`)).toBe(false); expect(hasStructurallyValidIdToken(`${header}.${payload}$.${signature}`)).toBe(false);
expect(hasStructurallyValidIdToken(`${header}=.${payload}.${signature}`)).toBe(false); expect(hasStructurallyValidIdToken(`${header}=.${payload}.${signature}`)).toBe(false);
}); });
it('rejects JWT segments with impossible base64url length', () => {
const [header, payload] = buildToken({}).split('.');
expect(hasStructurallyValidIdToken(`${header}.${payload}.a`)).toBe(false);
expect(decodeIdToken(`${header}.${payload}.a`)).toEqual({});
});
}); });
@@ -76,15 +76,27 @@ describe('resolveActiveProfile', () => {
expect(result).toBeNull(); expect(result).toBeNull();
expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true); expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true);
expect(stderrMessages.join('')).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
expect(stderrMessages.join('')).not.toContain(registryPath);
expect(stderrMessages.join('')).not.toContain('invalid yaml');
}); });
it('throws when CCS_CODEX_PROFILE is set and registry YAML is corrupt', () => { it('throws when CCS_CODEX_PROFILE is set and registry YAML is corrupt', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 }); fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 });
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' })).toThrow( let thrown: unknown;
/Refusing to fall back to ~\/\.codex/ try {
); resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
} catch (err) {
thrown = err;
}
expect(thrown).toBeDefined();
expect(String(thrown)).toContain('Refusing to fall back to ~/.codex');
expect(String(thrown)).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
expect(String(thrown)).not.toContain(registryPath);
expect(String(thrown)).not.toContain('invalid yaml');
}); });
it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => { it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => {
+40 -8
View File
@@ -31,16 +31,43 @@ describe('detectShell — Unix', () => {
}); });
describe('detectShell — Windows', () => { describe('detectShell — Windows', () => {
it('returns pwsh when PSModulePath is set', () => { it('does not treat PSModulePath alone as PowerShell', () => {
expect(detectShell({ PSModulePath: 'C:\\Windows\\system32\\...' }, 'win32')).toBe('pwsh'); expect(
detectShell(
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
'win32'
)
).toBe('cmd');
}); });
it('returns cmd when PSModulePath is absent', () => { it('returns pwsh when SHELL points to PowerShell', () => {
expect(detectShell({}, 'win32')).toBe('cmd'); expect(
detectShell(
{
SHELL: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
PSModulePath: 'C:\\Windows\\system32\\...',
},
'win32'
)
).toBe('pwsh');
}); });
it('ignores SHELL on Windows — uses PSModulePath heuristic', () => { it('returns pwsh when the parent process is PowerShell and ComSpec points to cmd', () => {
expect(detectShell({ SHELL: '/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('pwsh'); expect(
detectShell(
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
'win32',
'pwsh.exe'
)
).toBe('pwsh');
});
it('honors Git Bash style SHELL on Windows', () => {
expect(detectShell({ SHELL: '/usr/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('bash');
});
it('returns cmd when no explicit shell hint is available', () => {
expect(detectShell({ PSModulePath: 'C:\\ps' }, 'win32')).toBe('cmd');
}); });
}); });
@@ -92,6 +119,11 @@ describe('formatExport — pwsh', () => {
const result = formatExport('pwsh', 'X', 'say "hello"'); const result = formatExport('pwsh', 'X', 'say "hello"');
expect(result).toBe('$env:X = "say ""hello"""'); expect(result).toBe('$env:X = "say ""hello"""');
}); });
it('escapes backticks before interpolation-sensitive characters', () => {
const result = formatExport('pwsh', 'CODEX_HOME', 'C:\\Users\\kai`$tmp');
expect(result).toBe('$env:CODEX_HOME = "C:\\Users\\kai```$tmp"');
});
}); });
describe('formatExport — cmd', () => { describe('formatExport — cmd', () => {
@@ -108,8 +140,8 @@ describe('formatExport — cmd', () => {
}); });
it('escapes cmd expansion-sensitive characters', () => { it('escapes cmd expansion-sensitive characters', () => {
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted"')).toBe( expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted" !bang!')).toBe(
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^""' 'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^" ^^!bang^^!"'
); );
}); });
}); });
@@ -10,8 +10,9 @@
*/ */
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -28,6 +29,14 @@ import type { CodexAuthProfileEntry } from '@/hooks/use-codex-auth-profiles';
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
function InlineCode({ children }: { children?: ReactNode }) {
return (
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em] text-foreground">
{children}
</code>
);
}
function formatLastUsed(iso: string | null): string { function formatLastUsed(iso: string | null): string {
if (!iso) return 'never'; if (!iso) return 'never';
try { try {
@@ -60,8 +69,6 @@ function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home', t: TFunc
// ── Disabled action button with terminal-redirect tooltip ─────────────────── // ── Disabled action button with terminal-redirect tooltip ───────────────────
function TerminalOnlyButton({ label }: { label: string }) { function TerminalOnlyButton({ label }: { label: string }) {
const { t } = useTranslation();
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -73,7 +80,12 @@ function TerminalOnlyButton({ label }: { label: string }) {
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t('codex.auth.terminalOnlyTooltip')}</TooltipContent> <TooltipContent>
<Trans
i18nKey="codex.auth.terminalOnlyTooltipRich"
components={{ code: <InlineCode /> }}
/>
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
@@ -155,8 +167,12 @@ export function CodexAuthProfilesCard() {
if (data.profiles.length === 0) { if (data.profiles.length === 0) {
return ( return (
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1"> <div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1">
<p>{t('codex.auth.emptyRegistry')}</p> <p>
<p>{t('codex.auth.legacyCodexHome')}</p> <Trans i18nKey="codex.auth.emptyRegistryRich" components={{ code: <InlineCode /> }} />
</p>
<p>
<Trans i18nKey="codex.auth.legacyCodexHomeRich" components={{ code: <InlineCode /> }} />
</p>
</div> </div>
); );
} }
@@ -166,7 +182,7 @@ export function CodexAuthProfilesCard() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{t('codex.auth.legacyMode')} <Trans i18nKey="codex.auth.legacyModeRich" components={{ code: <InlineCode /> }} />
</div> </div>
<ProfileTable data={data} /> <ProfileTable data={data} />
</div> </div>
@@ -178,7 +194,11 @@ export function CodexAuthProfilesCard() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{t('codex.auth.externalCodexHome', { path: data.active.codexHome })} <Trans
i18nKey="codex.auth.externalCodexHomeRich"
values={{ path: data.active.codexHome }}
components={{ code: <InlineCode /> }}
/>
</div> </div>
<ProfileTable data={data} /> <ProfileTable data={data} />
</div> </div>
+50
View File
@@ -2178,17 +2178,26 @@ const resources = {
auth: { auth: {
terminalOnlyTooltip: terminalOnlyTooltip:
'Use ccsx auth switch <name> or ccsx auth remove <name> in terminal.', 'Use ccsx auth switch <name> or ccsx auth remove <name> in terminal.',
terminalOnlyTooltipRich:
'Use <code>ccsx auth switch &lt;name&gt;</code> or <code>ccsx auth remove &lt;name&gt;</code> in terminal.',
activeSourceBadge: '{{source}}', activeSourceBadge: '{{source}}',
statusOk: 'OK', statusOk: 'OK',
statusInvalid: '[!] auth invalid', statusInvalid: '[!] auth invalid',
loading: 'Loading auth profiles...', loading: 'Loading auth profiles...',
loadError: '[!] Failed to load codex-auth profiles.', loadError: '[!] Failed to load codex-auth profiles.',
emptyRegistry: '[i] No codex-auth profiles. Run ccsx auth create <name> to create one.', emptyRegistry: '[i] No codex-auth profiles. Run ccsx auth create <name> to create one.',
emptyRegistryRich:
'[i] No codex-auth profiles. Run <code>ccsx auth create &lt;name&gt;</code> to create one.',
legacyCodexHome: 'Codex will use the default ~/.codex location.', legacyCodexHome: 'Codex will use the default ~/.codex location.',
legacyCodexHomeRich: 'Codex will use the default <code>~/.codex</code> location.',
legacyMode: legacyMode:
'[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch <name> in terminal to activate one.', '[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch <name> in terminal to activate one.',
legacyModeRich:
'[i] No active profile. Using <code>~/.codex</code> (legacy). Run <code>ccsx auth switch &lt;name&gt;</code> in terminal to activate one.',
externalCodexHome: externalCodexHome:
'[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.', '[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> set externally to <code>{{path}}</code>. Profile registry not in use for this session.',
activeProfile: 'Active profile:', activeProfile: 'Active profile:',
unknownProfile: '(unknown)', unknownProfile: '(unknown)',
planLabel: 'Plan:', planLabel: 'Plan:',
@@ -2659,6 +2668,7 @@ const resources = {
controlCenter: 'Control Center', controlCenter: 'Control Center',
overview: 'Overview', overview: 'Overview',
docs: 'Docs', docs: 'Docs',
authProfiles: 'Auth Profiles',
nativeRuntime: 'Native Runtime', nativeRuntime: 'Native Runtime',
ccsProvider: 'CCS Provider', ccsProvider: 'CCS Provider',
setup: 'Setup', setup: 'Setup',
@@ -4772,16 +4782,25 @@ const resources = {
warningsTitle: '警告', warningsTitle: '警告',
auth: { auth: {
terminalOnlyTooltip: '在终端使用 ccsx auth switch <name> 或 ccsx auth remove <name>。', terminalOnlyTooltip: '在终端使用 ccsx auth switch <name> 或 ccsx auth remove <name>。',
terminalOnlyTooltipRich:
'在终端使用 <code>ccsx auth switch &lt;name&gt;</code> 或 <code>ccsx auth remove &lt;name&gt;</code>。',
activeSourceBadge: '{{source}}', activeSourceBadge: '{{source}}',
statusOk: '正常', statusOk: '正常',
statusInvalid: '[!] 认证无效', statusInvalid: '[!] 认证无效',
loading: '正在加载认证配置...', loading: '正在加载认证配置...',
loadError: '[!] 加载 codex-auth 配置失败。', loadError: '[!] 加载 codex-auth 配置失败。',
emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。', emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。',
emptyRegistryRich:
'[i] 没有 codex-auth 配置。运行 <code>ccsx auth create &lt;name&gt;</code> 创建一个。',
legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。', legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。',
legacyCodexHomeRich: 'Codex 将使用默认 <code>~/.codex</code> 位置。',
legacyMode: legacyMode:
'[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。', '[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。',
legacyModeRich:
'[i] 没有活动配置。正在使用 <code>~/.codex</code>(旧模式)。在终端运行 <code>ccsx auth switch &lt;name&gt;</code> 激活一个。',
externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。', externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> 外部设置为 <code>{{path}}</code>。本会话未使用配置注册表。',
activeProfile: '活动配置:', activeProfile: '活动配置:',
unknownProfile: '(未知)', unknownProfile: '(未知)',
planLabel: '套餐:', planLabel: '套餐:',
@@ -5228,6 +5247,7 @@ const resources = {
controlCenter: '控制中心', controlCenter: '控制中心',
overview: '概览', overview: '概览',
docs: '文档', docs: '文档',
authProfiles: '认证配置',
nativeRuntime: '原生运行时', nativeRuntime: '原生运行时',
ccsProvider: 'CCS 提供商', ccsProvider: 'CCS 提供商',
setup: '安装', setup: '安装',
@@ -7452,17 +7472,26 @@ const resources = {
auth: { auth: {
terminalOnlyTooltip: terminalOnlyTooltip:
'Dùng ccsx auth switch <name> hoặc ccsx auth remove <name> trong terminal.', 'Dùng ccsx auth switch <name> hoặc ccsx auth remove <name> trong terminal.',
terminalOnlyTooltipRich:
'Dùng <code>ccsx auth switch &lt;name&gt;</code> hoặc <code>ccsx auth remove &lt;name&gt;</code> trong terminal.',
activeSourceBadge: '{{source}}', activeSourceBadge: '{{source}}',
statusOk: 'OK', statusOk: 'OK',
statusInvalid: '[!] auth không hợp lệ', statusInvalid: '[!] auth không hợp lệ',
loading: 'Đang tải hồ sơ auth...', loading: 'Đang tải hồ sơ auth...',
loadError: '[!] Không tải được hồ sơ codex-auth.', loadError: '[!] Không tải được hồ sơ codex-auth.',
emptyRegistry: '[i] Chưa có hồ sơ codex-auth. Chạy ccsx auth create <name> để tạo.', emptyRegistry: '[i] Chưa có hồ sơ codex-auth. Chạy ccsx auth create <name> để tạo.',
emptyRegistryRich:
'[i] Chưa có hồ sơ codex-auth. Chạy <code>ccsx auth create &lt;name&gt;</code> để tạo.',
legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.', legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.',
legacyCodexHomeRich: 'Codex sẽ dùng vị trí mặc định <code>~/.codex</code>.',
legacyMode: legacyMode:
'[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch <name> trong terminal để kích hoạt.', '[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch <name> trong terminal để kích hoạt.',
legacyModeRich:
'[i] Chưa có hồ sơ active. Đang dùng <code>~/.codex</code> (legacy). Chạy <code>ccsx auth switch &lt;name&gt;</code> trong terminal để kích hoạt.',
externalCodexHome: externalCodexHome:
'[i] $CODEX_HOME được đặt bên ngoài là {{path}}. Registry hồ sơ không dùng trong phiên này.', '[i] $CODEX_HOME được đặt bên ngoài là {{path}}. Registry hồ sơ không dùng trong phiên này.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> được đặt bên ngoài là <code>{{path}}</code>. Registry hồ sơ không dùng trong phiên này.',
activeProfile: 'Hồ sơ active:', activeProfile: 'Hồ sơ active:',
unknownProfile: '(không rõ)', unknownProfile: '(không rõ)',
planLabel: 'Gói:', planLabel: 'Gói:',
@@ -7921,6 +7950,7 @@ const resources = {
controlCenter: 'Trung tâm điều khiển', controlCenter: 'Trung tâm điều khiển',
overview: 'Tổng quan', overview: 'Tổng quan',
docs: 'Tài liệu', docs: 'Tài liệu',
authProfiles: 'Hồ sơ auth',
nativeRuntime: 'Runtime gốc', nativeRuntime: 'Runtime gốc',
ccsProvider: 'CCS Provider', ccsProvider: 'CCS Provider',
setup: 'Thiết lập', setup: 'Thiết lập',
@@ -9859,6 +9889,8 @@ const resources = {
auth: { auth: {
terminalOnlyTooltip: terminalOnlyTooltip:
'ターミナルで ccsx auth switch <name> または ccsx auth remove <name> を使用します。', 'ターミナルで ccsx auth switch <name> または ccsx auth remove <name> を使用します。',
terminalOnlyTooltipRich:
'ターミナルで <code>ccsx auth switch &lt;name&gt;</code> または <code>ccsx auth remove &lt;name&gt;</code> を使用します。',
activeSourceBadge: '{{source}}', activeSourceBadge: '{{source}}',
statusOk: 'OK', statusOk: 'OK',
statusInvalid: '[!] 認証が無効', statusInvalid: '[!] 認証が無効',
@@ -9866,11 +9898,18 @@ const resources = {
loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。', loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。',
emptyRegistry: emptyRegistry:
'[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。', '[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。',
emptyRegistryRich:
'[i] codex-auth プロファイルがありません。<code>ccsx auth create &lt;name&gt;</code> を実行して作成します。',
legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。', legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。',
legacyCodexHomeRich: 'Codex はデフォルトの <code>~/.codex</code> を使用します。',
legacyMode: legacyMode:
'[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。', '[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。',
legacyModeRich:
'[i] アクティブなプロファイルがありません。<code>~/.codex</code>(レガシー)を使用中です。ターミナルで <code>ccsx auth switch &lt;name&gt;</code> を実行して有効化します。',
externalCodexHome: externalCodexHome:
'[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。', '[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> は外部で <code>{{path}}</code> に設定されています。このセッションではプロファイル registry は使われません。',
activeProfile: 'アクティブプロファイル:', activeProfile: 'アクティブプロファイル:',
unknownProfile: '(不明)', unknownProfile: '(不明)',
planLabel: 'プラン:', planLabel: 'プラン:',
@@ -9894,6 +9933,7 @@ const resources = {
controlCenter: 'コントロールセンター', controlCenter: 'コントロールセンター',
overview: '概要', overview: '概要',
docs: 'ドキュメント', docs: 'ドキュメント',
authProfiles: '認証プロファイル',
nativeRuntime: 'ネイティブランタイム', nativeRuntime: 'ネイティブランタイム',
ccsProvider: 'CCS プロバイダー', ccsProvider: 'CCS プロバイダー',
setup: 'セットアップ', setup: 'セットアップ',
@@ -12861,6 +12901,8 @@ const resources = {
auth: { auth: {
terminalOnlyTooltip: terminalOnlyTooltip:
'터미널에서 ccsx auth switch <name> 또는 ccsx auth remove <name>을 사용하세요.', '터미널에서 ccsx auth switch <name> 또는 ccsx auth remove <name>을 사용하세요.',
terminalOnlyTooltipRich:
'터미널에서 <code>ccsx auth switch &lt;name&gt;</code> 또는 <code>ccsx auth remove &lt;name&gt;</code>을 사용하세요.',
activeSourceBadge: '{{source}}', activeSourceBadge: '{{source}}',
statusOk: 'OK', statusOk: 'OK',
statusInvalid: '[!] 인증이 유효하지 않음', statusInvalid: '[!] 인증이 유효하지 않음',
@@ -12868,11 +12910,18 @@ const resources = {
loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.', loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.',
emptyRegistry: emptyRegistry:
'[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.', '[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.',
emptyRegistryRich:
'[i] codex-auth 프로필이 없습니다. <code>ccsx auth create &lt;name&gt;</code>을 실행해 생성하세요.',
legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.', legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.',
legacyCodexHomeRich: 'Codex는 기본 <code>~/.codex</code> 위치를 사용합니다.',
legacyMode: legacyMode:
'[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.', '[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.',
legacyModeRich:
'[i] 활성 프로필이 없습니다. <code>~/.codex</code>(레거시)를 사용 중입니다. 터미널에서 <code>ccsx auth switch &lt;name&gt;</code>을 실행해 활성화하세요.',
externalCodexHome: externalCodexHome:
'[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.', '[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code>이 외부에서 <code>{{path}}</code>로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
activeProfile: '활성 프로필:', activeProfile: '활성 프로필:',
unknownProfile: '(알 수 없음)', unknownProfile: '(알 수 없음)',
planLabel: '플랜:', planLabel: '플랜:',
@@ -13345,6 +13394,7 @@ const resources = {
controlCenter: '제어 센터', controlCenter: '제어 센터',
overview: '개요', overview: '개요',
docs: '문서', docs: '문서',
authProfiles: '인증 프로필',
nativeRuntime: '네이티브 런타임', nativeRuntime: '네이티브 런타임',
ccsProvider: 'CCS 프로바이더', ccsProvider: 'CCS 프로바이더',
setup: '설정', setup: '설정',
+1 -4
View File
@@ -183,10 +183,7 @@ export function CodexPage() {
<TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger> <TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger>
<TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger> <TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger>
<TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger> <TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger>
<TabsTrigger value="auth-profiles"> <TabsTrigger value="auth-profiles">{t('codexPage.authProfiles')}</TabsTrigger>
{/* TODO i18n: missing key codexPage.authProfiles */}
Auth Profiles
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
+26 -3
View File
@@ -4,15 +4,31 @@ import i18n from '@/lib/i18n';
const locales = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const; const locales = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const;
const codexAuthKeys = [ const codexAuthKeys = [
['codex.auth.terminalOnlyTooltip'], ['codex.auth.sourceDefault'],
['codex.auth.sourceEnv'],
['codex.auth.sourceExplicitCodexHome'],
['codex.auth.terminalOnlyTooltipRich'],
['codex.auth.activeSourceBadge', { source: 'default' }],
['codex.auth.statusOk'],
['codex.auth.statusInvalid'],
['codex.auth.loading'], ['codex.auth.loading'],
['codex.auth.loadError'], ['codex.auth.loadError'],
['codex.auth.emptyRegistry'], ['codex.auth.emptyRegistryRich'],
['codex.auth.externalCodexHome', { path: '/tmp/codex-home' }], ['codex.auth.legacyCodexHomeRich'],
['codex.auth.legacyModeRich'],
['codex.auth.externalCodexHomeRich', { path: '/tmp/codex-home' }],
['codex.auth.activeProfile'], ['codex.auth.activeProfile'],
['codex.auth.unknownProfile'],
['codex.auth.planLabel'],
['codex.auth.switchAction'], ['codex.auth.switchAction'],
['codex.auth.removeAction'],
['codex.auth.col.name'], ['codex.auth.col.name'],
['codex.auth.col.email'],
['codex.auth.col.plan'],
['codex.auth.col.lastUsed'],
['codex.auth.col.status'],
['codex.auth.col.actions'], ['codex.auth.col.actions'],
['codexPage.authProfiles'],
] as const; ] as const;
const originalLanguage = i18n.language; const originalLanguage = i18n.language;
@@ -30,6 +46,13 @@ describe('codex auth i18n', () => {
expect(translated).not.toBe(key); expect(translated).not.toBe(key);
expect(translated).not.toContain('codex.auth.'); expect(translated).not.toContain('codex.auth.');
expect(translated).not.toContain('codexPage.');
if (key === 'codex.auth.externalCodexHomeRich') {
expect(translated).toContain('/tmp/codex-home');
}
if (key === 'codex.auth.activeSourceBadge') {
expect(translated).toContain('default');
}
} }
}); });
}); });