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';
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, '^^!');
}
/**
+97 -15
View File
@@ -45,6 +45,20 @@ function makeProfileDir(name: string): string {
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(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
ccsHome = path.join(tempDir, 'ccs');
@@ -151,27 +165,95 @@ describe('codex-runtime router — non-auth profile resolution', () => {
});
process.env.CCS_CODEX_PROFILE = 'ghost';
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 {
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> };
const code = await main(['node', 'codex-runtime', 'chat']);
return main(['node', 'codex-runtime', 'chat']);
});
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderrMessages.join('')).toContain("CCS_CODEX_PROFILE='ghost'");
} finally {
process.stderr.write = origWrite;
}
expect(code).toBe(1);
expect(process.env.CODEX_HOME).toBeUndefined();
expect(stderr).toContain("CCS_CODEX_PROFILE='ghost'");
});
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 () => {
@@ -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', () => {
@@ -385,22 +424,43 @@ describe('import-default — torn-write retry', () => {
expect(exitCalled).toBe(true);
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', () => {
it('warns and refuses when pgrep finds a codex PID', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
// Mock spawnSync to simulate pgrep finding codex
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 0,
stdout: '12345\n',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
const { handleImportDefaultCodex } = await import(
'../../../../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 () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 0,
stdout: '12345\n',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
@@ -456,6 +508,57 @@ describe('import-default — Codex running detection', () => {
// Should have proceeded and created the profile
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', () => {
@@ -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', () => {
it('exits with BINARY_ERROR when codex not found', async () => {
const detectorMod = await import('../../../../src/targets/codex-detector');
@@ -149,4 +155,57 @@ describe('handleLoginCodex — clean exit updates registry', () => {
// last_used should now be set
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);
});
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 () => {
const { handleRemoveCodex } = await import(
'../../../../src/codex-auth/commands/remove-command'
@@ -269,4 +315,45 @@ describe('handleRemoveCodex — confirmation', () => {
expect(ctx.registry.hasProfile('restoreme')).toBe(true);
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('');
}
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 ────────────────────────────────────────────────────────────────
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) ─────────────────────────────────────────
describe('handleShowCodex — active(missing) at top', () => {
@@ -120,6 +168,19 @@ describe('handleShowCodex — detail view', () => {
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 () => {
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
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);
});
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(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', () => {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 });
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' })).toThrow(
/Refusing to fall back to ~\/\.codex/
);
let thrown: unknown;
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', () => {
+40 -8
View File
@@ -31,16 +31,43 @@ describe('detectShell — Unix', () => {
});
describe('detectShell — Windows', () => {
it('returns pwsh when PSModulePath is set', () => {
expect(detectShell({ PSModulePath: 'C:\\Windows\\system32\\...' }, 'win32')).toBe('pwsh');
it('does not treat PSModulePath alone as PowerShell', () => {
expect(
detectShell(
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
'win32'
)
).toBe('cmd');
});
it('returns cmd when PSModulePath is absent', () => {
expect(detectShell({}, 'win32')).toBe('cmd');
it('returns pwsh when SHELL points to PowerShell', () => {
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', () => {
expect(detectShell({ SHELL: '/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('pwsh');
it('returns pwsh when the parent process is PowerShell and ComSpec points to cmd', () => {
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"');
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', () => {
@@ -108,8 +140,8 @@ describe('formatExport — cmd', () => {
});
it('escapes cmd expansion-sensitive characters', () => {
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted"')).toBe(
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^""'
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted" !bang!')).toBe(
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^" ^^!bang^^!"'
);
});
});
@@ -10,8 +10,9 @@
*/
import { Loader2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -28,6 +29,14 @@ import type { CodexAuthProfileEntry } from '@/hooks/use-codex-auth-profiles';
// ── 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 {
if (!iso) return 'never';
try {
@@ -60,8 +69,6 @@ function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home', t: TFunc
// ── Disabled action button with terminal-redirect tooltip ───────────────────
function TerminalOnlyButton({ label }: { label: string }) {
const { t } = useTranslation();
return (
<TooltipProvider>
<Tooltip>
@@ -73,7 +80,12 @@ function TerminalOnlyButton({ label }: { label: string }) {
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t('codex.auth.terminalOnlyTooltip')}</TooltipContent>
<TooltipContent>
<Trans
i18nKey="codex.auth.terminalOnlyTooltipRich"
components={{ code: <InlineCode /> }}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -155,8 +167,12 @@ export function CodexAuthProfilesCard() {
if (data.profiles.length === 0) {
return (
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1">
<p>{t('codex.auth.emptyRegistry')}</p>
<p>{t('codex.auth.legacyCodexHome')}</p>
<p>
<Trans i18nKey="codex.auth.emptyRegistryRich" components={{ code: <InlineCode /> }} />
</p>
<p>
<Trans i18nKey="codex.auth.legacyCodexHomeRich" components={{ code: <InlineCode /> }} />
</p>
</div>
);
}
@@ -166,7 +182,7 @@ export function CodexAuthProfilesCard() {
return (
<div className="space-y-3">
<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>
<ProfileTable data={data} />
</div>
@@ -178,7 +194,11 @@ export function CodexAuthProfilesCard() {
return (
<div className="space-y-3">
<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>
<ProfileTable data={data} />
</div>
+50
View File
@@ -2178,17 +2178,26 @@ const resources = {
auth: {
terminalOnlyTooltip:
'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}}',
statusOk: 'OK',
statusInvalid: '[!] auth invalid',
loading: 'Loading auth profiles...',
loadError: '[!] Failed to load codex-auth profiles.',
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.',
legacyCodexHomeRich: 'Codex will use the default <code>~/.codex</code> location.',
legacyMode:
'[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:
'[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:',
unknownProfile: '(unknown)',
planLabel: 'Plan:',
@@ -2659,6 +2668,7 @@ const resources = {
controlCenter: 'Control Center',
overview: 'Overview',
docs: 'Docs',
authProfiles: 'Auth Profiles',
nativeRuntime: 'Native Runtime',
ccsProvider: 'CCS Provider',
setup: 'Setup',
@@ -4772,16 +4782,25 @@ const resources = {
warningsTitle: '警告',
auth: {
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}}',
statusOk: '正常',
statusInvalid: '[!] 认证无效',
loading: '正在加载认证配置...',
loadError: '[!] 加载 codex-auth 配置失败。',
emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。',
emptyRegistryRich:
'[i] 没有 codex-auth 配置。运行 <code>ccsx auth create &lt;name&gt;</code> 创建一个。',
legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。',
legacyCodexHomeRich: 'Codex 将使用默认 <code>~/.codex</code> 位置。',
legacyMode:
'[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。',
legacyModeRich:
'[i] 没有活动配置。正在使用 <code>~/.codex</code>(旧模式)。在终端运行 <code>ccsx auth switch &lt;name&gt;</code> 激活一个。',
externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> 外部设置为 <code>{{path}}</code>。本会话未使用配置注册表。',
activeProfile: '活动配置:',
unknownProfile: '(未知)',
planLabel: '套餐:',
@@ -5228,6 +5247,7 @@ const resources = {
controlCenter: '控制中心',
overview: '概览',
docs: '文档',
authProfiles: '认证配置',
nativeRuntime: '原生运行时',
ccsProvider: 'CCS 提供商',
setup: '安装',
@@ -7452,17 +7472,26 @@ const resources = {
auth: {
terminalOnlyTooltip:
'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}}',
statusOk: 'OK',
statusInvalid: '[!] auth không hợp lệ',
loading: 'Đang tải hồ sơ 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.',
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.',
legacyCodexHomeRich: 'Codex sẽ dùng vị trí mặc định <code>~/.codex</code>.',
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.',
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:
'[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:',
unknownProfile: '(không rõ)',
planLabel: 'Gói:',
@@ -7921,6 +7950,7 @@ const resources = {
controlCenter: 'Trung tâm điều khiển',
overview: 'Tổng quan',
docs: 'Tài liệu',
authProfiles: 'Hồ sơ auth',
nativeRuntime: 'Runtime gốc',
ccsProvider: 'CCS Provider',
setup: 'Thiết lập',
@@ -9859,6 +9889,8 @@ const resources = {
auth: {
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}}',
statusOk: 'OK',
statusInvalid: '[!] 認証が無効',
@@ -9866,11 +9898,18 @@ const resources = {
loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。',
emptyRegistry:
'[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。',
emptyRegistryRich:
'[i] codex-auth プロファイルがありません。<code>ccsx auth create &lt;name&gt;</code> を実行して作成します。',
legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。',
legacyCodexHomeRich: 'Codex はデフォルトの <code>~/.codex</code> を使用します。',
legacyMode:
'[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。',
legacyModeRich:
'[i] アクティブなプロファイルがありません。<code>~/.codex</code>(レガシー)を使用中です。ターミナルで <code>ccsx auth switch &lt;name&gt;</code> を実行して有効化します。',
externalCodexHome:
'[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code> は外部で <code>{{path}}</code> に設定されています。このセッションではプロファイル registry は使われません。',
activeProfile: 'アクティブプロファイル:',
unknownProfile: '(不明)',
planLabel: 'プラン:',
@@ -9894,6 +9933,7 @@ const resources = {
controlCenter: 'コントロールセンター',
overview: '概要',
docs: 'ドキュメント',
authProfiles: '認証プロファイル',
nativeRuntime: 'ネイティブランタイム',
ccsProvider: 'CCS プロバイダー',
setup: 'セットアップ',
@@ -12861,6 +12901,8 @@ const resources = {
auth: {
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}}',
statusOk: 'OK',
statusInvalid: '[!] 인증이 유효하지 않음',
@@ -12868,11 +12910,18 @@ const resources = {
loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.',
emptyRegistry:
'[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.',
emptyRegistryRich:
'[i] codex-auth 프로필이 없습니다. <code>ccsx auth create &lt;name&gt;</code>을 실행해 생성하세요.',
legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.',
legacyCodexHomeRich: 'Codex는 기본 <code>~/.codex</code> 위치를 사용합니다.',
legacyMode:
'[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.',
legacyModeRich:
'[i] 활성 프로필이 없습니다. <code>~/.codex</code>(레거시)를 사용 중입니다. 터미널에서 <code>ccsx auth switch &lt;name&gt;</code>을 실행해 활성화하세요.',
externalCodexHome:
'[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
externalCodexHomeRich:
'[i] <code>$CODEX_HOME</code>이 외부에서 <code>{{path}}</code>로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
activeProfile: '활성 프로필:',
unknownProfile: '(알 수 없음)',
planLabel: '플랜:',
@@ -13345,6 +13394,7 @@ const resources = {
controlCenter: '제어 센터',
overview: '개요',
docs: '문서',
authProfiles: '인증 프로필',
nativeRuntime: '네이티브 런타임',
ccsProvider: 'CCS 프로바이더',
setup: '설정',
+1 -4
View File
@@ -183,10 +183,7 @@ export function CodexPage() {
<TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger>
<TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger>
<TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger>
<TabsTrigger value="auth-profiles">
{/* TODO i18n: missing key codexPage.authProfiles */}
Auth Profiles
</TabsTrigger>
<TabsTrigger value="auth-profiles">{t('codexPage.authProfiles')}</TabsTrigger>
</TabsList>
</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 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.loadError'],
['codex.auth.emptyRegistry'],
['codex.auth.externalCodexHome', { path: '/tmp/codex-home' }],
['codex.auth.emptyRegistryRich'],
['codex.auth.legacyCodexHomeRich'],
['codex.auth.legacyModeRich'],
['codex.auth.externalCodexHomeRich', { path: '/tmp/codex-home' }],
['codex.auth.activeProfile'],
['codex.auth.unknownProfile'],
['codex.auth.planLabel'],
['codex.auth.switchAction'],
['codex.auth.removeAction'],
['codex.auth.col.name'],
['codex.auth.col.email'],
['codex.auth.col.plan'],
['codex.auth.col.lastUsed'],
['codex.auth.col.status'],
['codex.auth.col.actions'],
['codexPage.authProfiles'],
] as const;
const originalLanguage = i18n.language;
@@ -30,6 +46,13 @@ describe('codex auth i18n', () => {
expect(translated).not.toBe(key);
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');
}
}
});
});