diff --git a/src/bin/codex-runtime-router.ts b/src/bin/codex-runtime-router.ts index f4ad92f1..89135bd5 100644 --- a/src/bin/codex-runtime-router.ts +++ b/src/bin/codex-runtime-router.ts @@ -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 { } } } 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; } diff --git a/src/codex-auth/commands/import-default-command.ts b/src/codex-auth/commands/import-default-command.ts index 20499689..916ec115 100644 --- a/src/codex-auth/commands/import-default-command.ts +++ b/src/codex-auth/commands/import-default-command.ts @@ -38,7 +38,7 @@ function sleep(ms: number): Promise { } /** - * 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. diff --git a/src/codex-auth/commands/remove-command.ts b/src/codex-auth/commands/remove-command.ts index af208aa8..7f84e7cd 100644 --- a/src/codex-auth/commands/remove-command.ts +++ b/src/codex-auth/commands/remove-command.ts @@ -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, diff --git a/src/codex-auth/commands/show-command.ts b/src/codex-auth/commands/show-command.ts index ee5e9e25..a9cc23c3 100644 --- a/src/codex-auth/commands/show-command.ts +++ b/src/codex-auth/commands/show-command.ts @@ -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 { 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: '', 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 ?? ''; - if (fs.existsSync(authJsonPath) && !meta.email) { - const identity = decodeAccountIdentity(authJsonPath); - email = identity.email ?? ''; - } + const identity: CodexAccountIdentity = fs.existsSync(authJsonPath) + ? decodeAccountIdentity(authJsonPath) + : {}; + const email = meta.email ?? identity.email ?? ''; + 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 === '' ? 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, diff --git a/src/codex-auth/commands/show-detail-view.ts b/src/codex-auth/commands/show-detail-view.ts index 970db545..077af14a 100644 --- a/src/codex-auth/commands/show-detail-view.ts +++ b/src/codex-auth/commands/show-detail-view.ts @@ -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 ? '' : '')], ['Plan', meta.plan_type ?? (authExists ? '' : '')], - ['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'], diff --git a/src/codex-auth/decode-id-token.ts b/src/codex-auth/decode-id-token.ts index d56670c0..ce4889e1 100644 --- a/src/codex-auth/decode-id-token.ts +++ b/src/codex-auth/decode-id-token.ts @@ -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 { diff --git a/src/codex-auth/resolve-active-profile.ts b/src/codex-auth/resolve-active-profile.ts index 0d7a5667..71e6e791 100644 --- a/src/codex-auth/resolve-active-profile.ts +++ b/src/codex-auth/resolve-active-profile.ts @@ -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; } +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 { diff --git a/src/codex-auth/shell-detect.ts b/src/codex-auth/shell-detect.ts index b0635397..f1425f2d 100644 --- a/src/codex-auth/shell-detect.ts +++ b/src/codex-auth/shell-detect.ts @@ -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, '^^!'); } /** diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts index 93b55316..4f83c858 100644 --- a/tests/unit/bin/codex-runtime-router.test.ts +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -45,6 +45,20 @@ function makeProfileDir(name: string): string { return dir; } +async function withCapturedStderr(fn: () => Promise): 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 }; - 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 }; + 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 }; + 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 }; + 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 }; + 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 () => { diff --git a/tests/unit/codex-auth/commands/import-default-command.test.ts b/tests/unit/codex-auth/commands/import-default-command.test.ts index 52efe75d..98c7afa4 100644 --- a/tests/unit/codex-auth/commands/import-default-command.test.ts +++ b/tests/unit/codex-auth/commands/import-default-command.test.ts @@ -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', () => { diff --git a/tests/unit/codex-auth/commands/login-command.test.ts b/tests/unit/codex-auth/commands/login-command.test.ts index 7b7559ea..a650c2f0 100644 --- a/tests/unit/codex-auth/commands/login-command.test.ts +++ b/tests/unit/codex-auth/commands/login-command.test.ts @@ -53,6 +53,12 @@ function spawnReturnsCode(code: number, writeAuth = false) { }); } +function buildToken(payload: Record): 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; + } + ); + + 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'); + }); }); diff --git a/tests/unit/codex-auth/commands/remove-command.test.ts b/tests/unit/codex-auth/commands/remove-command.test.ts index 1ed141a8..e52f753a 100644 --- a/tests/unit/codex-auth/commands/remove-command.test.ts +++ b/tests/unit/codex-auth/commands/remove-command.test.ts @@ -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); + }); }); diff --git a/tests/unit/codex-auth/commands/show-command.test.ts b/tests/unit/codex-auth/commands/show-command.test.ts index d8e5bf02..bd557629 100644 --- a/tests/unit/codex-auth/commands/show-command.test.ts +++ b/tests/unit/codex-auth/commands/show-command.test.ts @@ -56,6 +56,12 @@ async function captureStdout(fn: () => Promise): Promise { return chunks.join(''); } +function buildToken(payload: Record): 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(''); }); + 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'); diff --git a/tests/unit/codex-auth/decode-id-token.test.ts b/tests/unit/codex-auth/decode-id-token.test.ts index 32a0bb08..2b7a7a9c 100644 --- a/tests/unit/codex-auth/decode-id-token.test.ts +++ b/tests/unit/codex-auth/decode-id-token.test.ts @@ -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({}); + }); }); diff --git a/tests/unit/codex-auth/resolve-active-profile.test.ts b/tests/unit/codex-auth/resolve-active-profile.test.ts index 1855ad9f..ed8ed3d8 100644 --- a/tests/unit/codex-auth/resolve-active-profile.test.ts +++ b/tests/unit/codex-auth/resolve-active-profile.test.ts @@ -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', () => { diff --git a/tests/unit/codex-auth/shell-detect.test.ts b/tests/unit/codex-auth/shell-detect.test.ts index 11856556..a3ee6ac8 100644 --- a/tests/unit/codex-auth/shell-detect.test.ts +++ b/tests/unit/codex-auth/shell-detect.test.ts @@ -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^^!"' ); }); }); diff --git a/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx b/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx index c00b02e2..50268ed3 100644 --- a/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx +++ b/ui/src/components/compatible-cli/codex-auth-profiles-card.tsx @@ -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 ( + + {children} + + ); +} + 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 ( @@ -73,7 +80,12 @@ function TerminalOnlyButton({ label }: { label: string }) { - {t('codex.auth.terminalOnlyTooltip')} + + }} + /> + ); @@ -155,8 +167,12 @@ export function CodexAuthProfilesCard() { if (data.profiles.length === 0) { return (
-

{t('codex.auth.emptyRegistry')}

-

{t('codex.auth.legacyCodexHome')}

+

+ }} /> +

+

+ }} /> +

); } @@ -166,7 +182,7 @@ export function CodexAuthProfilesCard() { return (
- {t('codex.auth.legacyMode')} + }} />
@@ -178,7 +194,11 @@ export function CodexAuthProfilesCard() { return (
- {t('codex.auth.externalCodexHome', { path: data.active.codexHome })} + }} + />
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 70ade74b..05d5009b 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2178,17 +2178,26 @@ const resources = { auth: { terminalOnlyTooltip: 'Use ccsx auth switch or ccsx auth remove in terminal.', + terminalOnlyTooltipRich: + 'Use ccsx auth switch <name> or ccsx auth remove <name> 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 to create one.', + emptyRegistryRich: + '[i] No codex-auth profiles. Run ccsx auth create <name> to create one.', legacyCodexHome: 'Codex will use the default ~/.codex location.', + legacyCodexHomeRich: 'Codex will use the default ~/.codex location.', legacyMode: '[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch in terminal to activate one.', + legacyModeRich: + '[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch <name> in terminal to activate one.', externalCodexHome: '[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.', + externalCodexHomeRich: + '[i] $CODEX_HOME set externally to {{path}}. 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 或 ccsx auth remove 。', + terminalOnlyTooltipRich: + '在终端使用 ccsx auth switch <name>ccsx auth remove <name>。', activeSourceBadge: '{{source}}', statusOk: '正常', statusInvalid: '[!] 认证无效', loading: '正在加载认证配置...', loadError: '[!] 加载 codex-auth 配置失败。', emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create 创建一个。', + emptyRegistryRich: + '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。', legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。', + legacyCodexHomeRich: 'Codex 将使用默认 ~/.codex 位置。', legacyMode: '[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch 激活一个。', + legacyModeRich: + '[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。', externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。', + externalCodexHomeRich: + '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。', 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 hoặc ccsx auth remove trong terminal.', + terminalOnlyTooltipRich: + 'Dùng ccsx auth switch <name> hoặc ccsx auth remove <name> 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 để tạo.', + emptyRegistryRich: + '[i] Chưa có hồ sơ codex-auth. Chạy ccsx auth create <name> để tạo.', legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.', + legacyCodexHomeRich: 'Codex sẽ dùng vị trí mặc định ~/.codex.', legacyMode: '[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch trong terminal để kích hoạt.', + legacyModeRich: + '[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch <name> 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] $CODEX_HOME được đặt bên ngoài là {{path}}. 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 または ccsx auth remove を使用します。', + terminalOnlyTooltipRich: + 'ターミナルで ccsx auth switch <name> または ccsx auth remove <name> を使用します。', activeSourceBadge: '{{source}}', statusOk: 'OK', statusInvalid: '[!] 認証が無効', @@ -9866,11 +9898,18 @@ const resources = { loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。', emptyRegistry: '[i] codex-auth プロファイルがありません。ccsx auth create を実行して作成します。', + emptyRegistryRich: + '[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。', legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。', + legacyCodexHomeRich: 'Codex はデフォルトの ~/.codex を使用します。', legacyMode: '[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch を実行して有効化します。', + legacyModeRich: + '[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。', externalCodexHome: '[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。', + externalCodexHomeRich: + '[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル 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 또는 ccsx auth remove 을 사용하세요.', + terminalOnlyTooltipRich: + '터미널에서 ccsx auth switch <name> 또는 ccsx auth remove <name>을 사용하세요.', activeSourceBadge: '{{source}}', statusOk: 'OK', statusInvalid: '[!] 인증이 유효하지 않음', @@ -12868,11 +12910,18 @@ const resources = { loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.', emptyRegistry: '[i] codex-auth 프로필이 없습니다. ccsx auth create 을 실행해 생성하세요.', + emptyRegistryRich: + '[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.', legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.', + legacyCodexHomeRich: 'Codex는 기본 ~/.codex 위치를 사용합니다.', legacyMode: '[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch 을 실행해 활성화하세요.', + legacyModeRich: + '[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.', externalCodexHome: '[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.', + externalCodexHomeRich: + '[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.', activeProfile: '활성 프로필:', unknownProfile: '(알 수 없음)', planLabel: '플랜:', @@ -13345,6 +13394,7 @@ const resources = { controlCenter: '제어 센터', overview: '개요', docs: '문서', + authProfiles: '인증 프로필', nativeRuntime: '네이티브 런타임', ccsProvider: 'CCS 프로바이더', setup: '설정', diff --git a/ui/src/pages/codex.tsx b/ui/src/pages/codex.tsx index 296156a5..eb04fca3 100644 --- a/ui/src/pages/codex.tsx +++ b/ui/src/pages/codex.tsx @@ -183,10 +183,7 @@ export function CodexPage() { {t('codexPage.overview')} {t('codexPage.controlCenter')} {t('codexPage.docs')} - - {/* TODO i18n: missing key codexPage.authProfiles */} - Auth Profiles - + {t('codexPage.authProfiles')} diff --git a/ui/tests/unit/lib/i18n-codex-auth.test.ts b/ui/tests/unit/lib/i18n-codex-auth.test.ts index 53c48416..757fc7f3 100644 --- a/ui/tests/unit/lib/i18n-codex-auth.test.ts +++ b/ui/tests/unit/lib/i18n-codex-auth.test.ts @@ -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'); + } } }); });