diff --git a/docs/codex-auth.md b/docs/codex-auth.md index 0058d993..777c600d 100644 --- a/docs/codex-auth.md +++ b/docs/codex-auth.md @@ -50,7 +50,7 @@ codex # runs with CODEX_HOME=~/.ccs/codex-instances/pers |---------|-------------| | `ccsx auth create ` | Create profile dir + auto-login | | `ccsx auth login ` | (Re-)authenticate an existing profile | -| `ccsx auth switch ` | Set the persistent default profile (all new shells) | +| `ccsx auth switch ` | Set the persistent default profile for future `ccsx` launches | | `ccsx auth use ` | Emit shell exports for this shell only (use with `eval`) | | `ccsx auth show [name]` | List all profiles or show details for one | | `ccsx auth remove ` | Delete profile dir + registry entry | @@ -60,9 +60,12 @@ codex # runs with CODEX_HOME=~/.ccs/codex-instances/pers | Method | Scope | How | |--------|-------|-----| -| `ccsx auth switch ` | All future shells | Writes to `~/.ccs/codex-profiles.yaml` | +| `ccsx auth switch ` | Future `ccsx` launches | Writes to `~/.ccs/codex-profiles.yaml` | | `eval "$(ccsx auth use )"` | Current shell only | Sets `CODEX_HOME` + `CCS_CODEX_PROFILE` in your shell | +Native `codex` shells only see the persistent default when launched through the `ccsx` +Codex runtime. For an already-open shell or a plain native `codex` binary, use `auth use`. + Shell syntax for `use`: ```bash diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index b200eaed..a0b66f3d 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -22,6 +22,7 @@ const slowTests = [ 'tests/integration/cursor-daemon-lifecycle.test.ts', 'tests/integration/logging-request-context.test.ts', 'tests/integration/proxy/daemon-lifecycle.test.ts', + 'tests/integration/web-server/codex-profiles-endpoint.test.ts', 'tests/unit/commands/persist-command-handler.test.ts', 'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts', 'tests/unit/hooks/browser-mcp-downloads-and-files.test.ts', diff --git a/src/bin/codex-runtime-router.ts b/src/bin/codex-runtime-router.ts index 04d29764..e04c8ef2 100644 --- a/src/bin/codex-runtime-router.ts +++ b/src/bin/codex-runtime-router.ts @@ -54,9 +54,10 @@ export async function main(argv: string[]): Promise { // ── non-auth branch: profile resolution ───────────────────────────────── - // F1: respect an explicit CODEX_HOME — ccsxp, user export, CI override, etc. + // F1: respect explicit CODEX_HOME unless CCS_CODEX_PROFILE asks for a managed profile. const explicit = (process.env.CODEX_HOME ?? '').trim(); - if (!explicit) { + const profileOverride = (process.env.CCS_CODEX_PROFILE ?? '').trim(); + if (!explicit || profileOverride) { try { const { resolveActiveProfile } = require('../codex-auth/resolve-active-profile') as { resolveActiveProfile: ( @@ -65,6 +66,11 @@ export async function main(argv: string[]): Promise { }; const resolved = resolveActiveProfile(process.env); if (resolved) { + if (explicit && explicit !== resolved.dir) { + process.stderr.write( + `[!] codex-auth: CCS_CODEX_PROFILE=${profileOverride} overrides existing CODEX_HOME.\n` + ); + } process.env.CODEX_HOME = resolved.dir; try { diff --git a/src/codex-auth/codex-config-symlink.ts b/src/codex-auth/codex-config-symlink.ts index 9a14499b..7857677d 100644 --- a/src/codex-auth/codex-config-symlink.ts +++ b/src/codex-auth/codex-config-symlink.ts @@ -5,6 +5,10 @@ import { getSharedCodexConfigPath } from './codex-profile-paths'; const logger = createLogger('codex-auth:symlink'); +export interface EnsureSharedConfigSymlinkOptions { + overwriteRegularFile?: boolean; +} + /** * Ensure /config.toml points at the shared ~/.codex/config.toml. * Self-healing: recreates stale or missing symlinks. If symlink creation is @@ -14,7 +18,11 @@ const logger = createLogger('codex-auth:symlink'); * @param sharedConfigPath - Override for the shared target path (used in tests * to avoid touching real ~/.codex/config.toml). Defaults to getSharedCodexConfigPath(). */ -export function ensureSharedConfigSymlink(profileDir: string, sharedConfigPath?: string): void { +export function ensureSharedConfigSymlink( + profileDir: string, + sharedConfigPath?: string, + options: EnsureSharedConfigSymlinkOptions = {} +): void { const targetPath = sharedConfigPath ?? getSharedCodexConfigPath(); const linkPath = path.join(profileDir, 'config.toml'); @@ -57,12 +65,25 @@ export function ensureSharedConfigSymlink(profileDir: string, sharedConfigPath?: was: currentTarget, now: targetPath, }); - } else { - // Regular file or other non-symlink entry — overwrite with warning + } else if ( + options.overwriteRegularFile || + isRegularConfigCopyUnmodified(linkPath, targetPath) + ) { + // Explicit repair or unchanged fallback copy — replace with a shared symlink when possible. process.stderr.write( `[!] codex-auth: overwriting regular file at ${linkPath} with symlink to shared config.toml\n` ); fs.unlinkSync(linkPath); + } else { + process.stderr.write( + `[!] codex-auth: preserving existing regular config.toml at ${linkPath}; ` + + `use ccsx auth create --force to refresh it.\n` + ); + logger.warn('codex-auth.symlink-regular-file-preserved', 'Preserved regular config.toml', { + link: linkPath, + target: targetPath, + }); + return; } } @@ -77,6 +98,14 @@ export function ensureSharedConfigSymlink(profileDir: string, sharedConfigPath?: } } +function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): boolean { + try { + return fs.readFileSync(linkPath, 'utf8') === fs.readFileSync(targetPath, 'utf8'); + } catch { + return false; + } +} + function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void { fs.copyFileSync(targetPath, linkPath); fs.chmodSync(linkPath, 0o600); diff --git a/src/codex-auth/codex-profile-paths.ts b/src/codex-auth/codex-profile-paths.ts index 98deb6eb..cd8f5d5d 100644 --- a/src/codex-auth/codex-profile-paths.ts +++ b/src/codex-auth/codex-profile-paths.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as os from 'os'; import { getCcsDir } from '../utils/config-manager'; +import { getCodexProfileNameError } from './types'; export function getCodexAuthRegistryPath(): string { return path.join(getCcsDir(), 'codex-profiles.yaml'); @@ -11,7 +12,18 @@ export function getCodexInstancesDir(): string { } export function resolveCodexProfileDir(name: string): string { - return path.join(getCodexInstancesDir(), name); + const nameError = getCodexProfileNameError(name); + if (nameError) { + throw new Error(nameError); + } + + const instancesDir = path.resolve(getCodexInstancesDir()); + const profileDir = path.resolve(path.join(instancesDir, name)); + if (profileDir !== instancesDir && profileDir.startsWith(`${instancesDir}${path.sep}`)) { + return profileDir; + } + + throw new Error('Profile directory resolved outside codex-instances.'); } // Uses os.homedir() intentionally — this is the upstream Codex location, diff --git a/src/codex-auth/codex-profile-registry.ts b/src/codex-auth/codex-profile-registry.ts index f742fb5f..e9a6d617 100644 --- a/src/codex-auth/codex-profile-registry.ts +++ b/src/codex-auth/codex-profile-registry.ts @@ -4,7 +4,8 @@ import * as yaml from 'js-yaml'; import * as lockfile from 'proper-lockfile'; import { createLogger } from '../services/logging'; import { getCodexAuthRegistryPath } from './codex-profile-paths'; -import { CODEX_PROFILE_SCHEMA_VERSION } from './types'; +import { getCcsDirSource } from '../utils/config-manager'; +import { CODEX_PROFILE_SCHEMA_VERSION, getCodexProfileNameError } from './types'; import type { CodexProfileData, CodexProfileMetadata } from './types'; const logger = createLogger('codex-auth:registry'); @@ -35,14 +36,95 @@ function validateRegistryData(parsed: unknown): CodexProfileData { if (data.default !== undefined && data.default !== null && typeof data.default !== 'string') { throw new Error('registry YAML default must be a string or null'); } + if (typeof data.default === 'string') { + assertValidProfileName(data.default); + } + + const profiles: Record = {}; + for (const [name, profile] of Object.entries(data.profiles)) { + assertValidProfileName(name); + profiles[name] = validateProfileMetadata(name, profile); + } + if ( + typeof data.default === 'string' && + !Object.prototype.hasOwnProperty.call(profiles, data.default) + ) { + throw new Error('registry YAML default profile is missing from profiles map'); + } return { version: typeof data.version === 'string' ? data.version : CODEX_PROFILE_SCHEMA_VERSION, default: data.default ?? null, - profiles: data.profiles as Record, + profiles, }; } +function assertValidProfileName(name: string): void { + const nameError = getCodexProfileNameError(name); + if (nameError) { + throw new Error(`registry YAML contains invalid profile name "${name}": ${nameError}`); + } +} + +function validateProfileMetadata(name: string, profile: unknown): CodexProfileMetadata { + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + throw new Error(`registry YAML profile "${name}" must be an object`); + } + const meta = profile as Partial; + if (meta.type !== 'codex') { + throw new Error(`registry YAML profile "${name}" must have type "codex"`); + } + if (typeof meta.created !== 'string') { + throw new Error(`registry YAML profile "${name}" must have a string created timestamp`); + } + if (meta.last_used !== null && typeof meta.last_used !== 'string') { + throw new Error( + `registry YAML profile "${name}" must have a string or null last_used timestamp` + ); + } + if (meta.email !== undefined && typeof meta.email !== 'string') { + throw new Error(`registry YAML profile "${name}" email must be a string`); + } + if ( + meta.plan_type !== undefined && + meta.plan_type !== null && + typeof meta.plan_type !== 'string' + ) { + throw new Error(`registry YAML profile "${name}" plan_type must be a string or null`); + } + if (meta.account_id !== undefined && typeof meta.account_id !== 'string') { + throw new Error(`registry YAML profile "${name}" account_id must be a string`); + } + return meta as CodexProfileMetadata; +} + +function registryDisplayPath(registryPath: string): string { + const [source] = getCcsDirSource(); + const defaultRegistryPath = path.resolve(getCodexAuthRegistryPath()); + if (path.resolve(registryPath) !== defaultRegistryPath) { + return path.basename(registryPath); + } + 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 'codex-profiles.yaml'; +} + +function safeRegistryReadMessage(err: unknown): string { + if ((err as { name?: unknown } | undefined)?.name === 'YAMLException') { + return 'registry YAML could not be parsed'; + } + return err instanceof Error ? err.message : String(err); +} + function sleepSync(ms: number): void { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); @@ -84,13 +166,14 @@ export class CodexProfileRegistry { const raw = fs.readFileSync(this.registryPath, 'utf8'); return validateRegistryData(yaml.load(raw)); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = safeRegistryReadMessage(err); + const displayPath = registryDisplayPath(this.registryPath); logger.warn( 'codex-auth.registry.read-failed', - `Registry at ${this.registryPath} could not be read safely; refusing empty-state rewrite: ${msg}` + `Registry at ${displayPath} could not be read safely; refusing empty-state rewrite: ${msg}` ); throw new CodexProfileRegistryReadError( - `Codex profile registry at ${this.registryPath} could not be read safely: ${msg}. Refusing to rewrite it.` + `Codex profile registry at ${displayPath} could not be read safely: ${msg}. Refusing to rewrite it.` ); } } @@ -187,6 +270,7 @@ export class CodexProfileRegistry { // ── CRUD ──────────────────────────────────────────────────────────────── createProfile(name: string, meta: Partial = {}): void { + assertValidProfileName(name); this._withRegistryWriteLock(() => { const data = this._read(); if (data.profiles[name]) { @@ -204,6 +288,7 @@ export class CodexProfileRegistry { } getProfile(name: string): CodexProfileMetadata { + assertValidProfileName(name); const data = this._read(); const profile = data.profiles[name]; if (!profile) { @@ -213,6 +298,7 @@ export class CodexProfileRegistry { } updateProfile(name: string, partial: Partial): void { + assertValidProfileName(name); this._withRegistryWriteLock(() => { const data = this._read(); if (!data.profiles[name]) { @@ -224,6 +310,7 @@ export class CodexProfileRegistry { } removeProfile(name: string): void { + assertValidProfileName(name); this._withRegistryWriteLock(() => { const data = this._read(); if (!data.profiles[name]) { @@ -244,6 +331,7 @@ export class CodexProfileRegistry { } hasProfile(name: string): boolean { + if (getCodexProfileNameError(name)) return false; return Object.prototype.hasOwnProperty.call(this._read().profiles, name); } @@ -254,6 +342,7 @@ export class CodexProfileRegistry { } setDefault(name: string): void { + assertValidProfileName(name); this._withRegistryWriteLock(() => { const data = this._read(); if (!data.profiles[name]) { diff --git a/src/codex-auth/commands/create-command.ts b/src/codex-auth/commands/create-command.ts index 0d975987..c0e510f9 100644 --- a/src/codex-auth/commands/create-command.ts +++ b/src/codex-auth/commands/create-command.ts @@ -23,7 +23,7 @@ const logger = createLogger('codex-auth:cmd:create'); export async function handleCreateCodex(ctx: CodexCommandContext, args: string[]): Promise { await initUI(); const parsed = parseArgs(args); - rejectUnsupportedOptions(parsed, 'ccsx auth create [--force]'); + rejectUnsupportedOptions(parsed, 'ccsx auth create [--force]', { force: true }); const { profileName, force } = parsed; @@ -47,11 +47,13 @@ export async function handleCreateCodex(ctx: CodexCommandContext, args: string[] if (force) { // --force: only re-link config.toml, preserve auth.json console.log(info(`Profile already exists: ${profileName} (re-linking config.toml)`)); - _ensureSymlinkSafe(profileDir); + _ensureSymlinkSafe(profileDir, true); console.log(ok(`Profile config.toml re-linked.`)); console.log(` Profile dir: ${profileDir}`); } else { + _ensureSymlinkSafe(profileDir); console.log(info(`Profile already exists: ${profileName}`)); + console.log(ok(`Profile config.toml is ready.`)); console.log(` Profile dir: ${profileDir}`); console.log(` Run: ccsx auth login ${profileName}`); } @@ -106,18 +108,16 @@ export async function handleCreateCodex(ctx: CodexCommandContext, args: string[] await _spawnLogin(profileName, profileDir, ctx); } -function _ensureSymlinkSafe(profileDir: string): void { +function _ensureSymlinkSafe(profileDir: string, overwriteRegularFile = false): void { try { - ensureSharedConfigSymlink(profileDir); + ensureSharedConfigSymlink(profileDir, undefined, { overwriteRegularFile }); } catch (err) { - // Symlink creation failure — warn + continue (Windows fallback documented) - process.stderr.write( - `[!] Symlinks unavailable; using copy. config.toml edits won't propagate.\n` - ); - logger.warn('codex-auth.create.symlink-failed', 'Symlink creation failed', { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('codex-auth.create.config-repair-failed', 'Config repair failed', { profileDir, - error: err instanceof Error ? err.message : String(err), + error: msg, }); + exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR); } } @@ -139,7 +139,7 @@ async function _spawnLogin( console.log(` CODEX_HOME=${profileDir}`); console.log(''); - await new Promise((resolve) => { + const loginResult = await new Promise<{ code: number; error?: string }>((resolve) => { const child = childProcess.spawn(codexCli, ['login'], { stdio: 'inherit', env: { ...process.env, CODEX_HOME: profileDir }, @@ -148,33 +148,41 @@ async function _spawnLogin( child.on('error', (err) => { process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`); - resolve(); + resolve({ code: ExitCode.BINARY_ERROR, error: err.message }); }); child.on('exit', (code) => { - const authJsonPath = path.join(profileDir, 'auth.json'); - if (code === 0 && fs.existsSync(authJsonPath)) { - const identity = decodeAccountIdentity(authJsonPath); - ctx.registry.updateProfile(profileName, { - last_used: new Date().toISOString(), - email: identity.email, - plan_type: identity.plan_type ?? null, - account_id: identity.account_id, - }); - const emailStr = identity.email ? ` as ${identity.email}` : ''; - const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : ''; - console.log(ok(`Logged in${emailStr}${planStr}`)); - } else if (code === 0) { - process.stderr.write( - `[!] codex login exited cleanly but no auth.json. Skipping registry update.\n` - ); - } else { - process.stderr.write( - `[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n` - ); - process.stderr.write(` Retry: ccsx auth login ${profileName}\n`); - } - resolve(); + resolve({ code: code ?? 1 }); }); }); + + if (loginResult.error) { + exitWithError(`codex login failed to start: ${loginResult.error}`, ExitCode.BINARY_ERROR); + return; + } + + const authJsonPath = path.join(profileDir, 'auth.json'); + if (loginResult.code === 0 && fs.existsSync(authJsonPath)) { + const identity = decodeAccountIdentity(authJsonPath); + ctx.registry.updateProfile(profileName, { + last_used: new Date().toISOString(), + email: identity.email, + plan_type: identity.plan_type ?? null, + account_id: identity.account_id, + }); + const emailStr = identity.email ? ` as ${identity.email}` : ''; + const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : ''; + console.log(ok(`Logged in${emailStr}${planStr}`)); + } else if (loginResult.code === 0) { + process.stderr.write( + `[!] codex login exited cleanly but no auth.json. Skipping registry update.\n` + ); + exitWithError('codex login completed without auth.json', ExitCode.AUTH_ERROR); + } else { + process.stderr.write( + `[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n` + ); + process.stderr.write(` Retry: ccsx auth login ${profileName}\n`); + exitWithError('codex login failed', ExitCode.AUTH_ERROR); + } } diff --git a/src/codex-auth/commands/import-default-command.ts b/src/codex-auth/commands/import-default-command.ts index 916ec115..d4352813 100644 --- a/src/codex-auth/commands/import-default-command.ts +++ b/src/codex-auth/commands/import-default-command.ts @@ -19,7 +19,7 @@ import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { resolveCodexProfileDir, ensureSharedConfigSymlink, decodeIdToken } from '../index'; import { hasStructurallyValidIdToken } from '../decode-id-token'; -import { parseArgs, getProfileNameError } from './types'; +import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; import type { CodexCommandContext } from './types'; const logger = createLogger('codex-auth:cmd:import-default'); @@ -30,6 +30,8 @@ const RETRY_DELAY_MS = 100; // CLIProxy format marker (reject these with a clear message) const CLIPROXY_TYPE_MARKER = 'type'; +const IMPORT_DEFAULT_USAGE = + 'ccsx auth import-default [--with-history] [--force] [--force-while-running]'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -249,6 +251,11 @@ export interface ImportDefaultArgs { function parseImportDefaultArgs(rawArgs: string[]): ImportDefaultArgs | null { // --with-history, --force, --force-while-running are distinct flags const parsed = parseArgs(rawArgs); + const unknownFlags = parsed.unknownFlags?.filter( + (flag) => flag !== '--with-history' && flag !== '--force-while-running' + ); + rejectUnsupportedOptions({ ...parsed, unknownFlags }, IMPORT_DEFAULT_USAGE, { force: true }); + const withHistory = rawArgs.includes('--with-history'); const forceWhileRunning = rawArgs.includes('--force-while-running'); @@ -270,9 +277,7 @@ export async function handleImportDefaultCodex( const args = parseImportDefaultArgs(rawArgs); if (!args) { - console.log( - `Usage: ccsx auth import-default [--with-history] [--force] [--force-while-running]` - ); + console.log(`Usage: ${IMPORT_DEFAULT_USAGE}`); exitWithError('Profile name required', ExitCode.PROFILE_ERROR); return; } @@ -379,11 +384,13 @@ export async function handleImportDefaultCodex( try { ensureSharedConfigSymlink(profileDir); } catch (err) { - process.stderr.write(`[!] Symlinks unavailable; config.toml edits won't propagate.\n`); + const msg = err instanceof Error ? err.message : String(err); logger.warn('codex-auth.import-default.symlink-failed', 'Symlink creation failed', { profileDir, - error: err instanceof Error ? err.message : String(err), + error: msg, }); + exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR); + return; } // Decode email for display (best-effort) diff --git a/src/codex-auth/commands/login-command.ts b/src/codex-auth/commands/login-command.ts index c1e17ac5..99cf54d4 100644 --- a/src/codex-auth/commands/login-command.ts +++ b/src/codex-auth/commands/login-command.ts @@ -40,21 +40,16 @@ export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]) } const { registry } = ctx; + const profileDir = resolveCodexProfileDir(profileName); // Auto-create profile if missing if (!registry.hasProfile(profileName)) { console.log(info(`Auto-creating profile ${profileName}`)); + ensureProfileDirReady(profileDir); registry.createProfile(profileName, { created: new Date().toISOString(), last_used: null, }); - const profileDir = resolveCodexProfileDir(profileName); - fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); - try { - ensureSharedConfigSymlink(profileDir); - } catch { - process.stderr.write(`[!] Symlink creation failed; continuing without shared config.\n`); - } } const codexCli = detectCodexCli(); @@ -70,16 +65,9 @@ export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]) return; } - const profileDir = resolveCodexProfileDir(profileName); - // Ensure profile dir exists (may have been deleted) if (!fs.existsSync(profileDir)) { - fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); - try { - ensureSharedConfigSymlink(profileDir); - } catch { - process.stderr.write(`[!] Symlink creation failed; continuing.\n`); - } + ensureProfileDirReady(profileDir); } const authJsonPath = path.join(profileDir, 'auth.json'); @@ -136,3 +124,17 @@ export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]) process.exit(ExitCode.AUTH_ERROR); } } + +function ensureProfileDirReady(profileDir: string): void { + try { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + ensureSharedConfigSymlink(profileDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('codex-auth.login.config-repair-failed', 'Config repair failed', { + profileDir, + error: msg, + }); + exitWithError(`Failed to prepare profile config.toml: ${msg}`, ExitCode.CONFIG_ERROR); + } +} diff --git a/src/codex-auth/commands/remove-command.ts b/src/codex-auth/commands/remove-command.ts index 7f84e7cd..f28a6224 100644 --- a/src/codex-auth/commands/remove-command.ts +++ b/src/codex-auth/commands/remove-command.ts @@ -21,7 +21,10 @@ import type { CodexProfileMetadata } from '../types'; export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise { await initUI(); const parsed = parseArgs(args); - rejectUnsupportedOptions(parsed, 'ccsx auth remove [--yes|-y] [--force]'); + rejectUnsupportedOptions(parsed, 'ccsx auth remove [--yes|-y] [--force]', { + yes: true, + force: true, + }); const { profileName, yes, force } = parsed; diff --git a/src/codex-auth/commands/show-command.ts b/src/codex-auth/commands/show-command.ts index a9cc23c3..50a44dd2 100644 --- a/src/codex-auth/commands/show-command.ts +++ b/src/codex-auth/commands/show-command.ts @@ -19,7 +19,7 @@ import type { CodexAccountIdentity } from '../types'; export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise { await initUI(); const parsed = parseArgs(args); - rejectUnsupportedOptions(parsed, 'ccsx auth show [name] [--json]'); + rejectUnsupportedOptions(parsed, 'ccsx auth show [name] [--json]', { json: true }); const { profileName, json } = parsed; diff --git a/src/codex-auth/commands/types.ts b/src/codex-auth/commands/types.ts index 386b1692..04eb5829 100644 --- a/src/codex-auth/commands/types.ts +++ b/src/codex-auth/commands/types.ts @@ -6,6 +6,7 @@ import { color } from '../../utils/ui'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import type { CodexProfileRegistry } from '../codex-profile-registry'; +import { getCodexProfileNameError, isValidCodexProfileName } from '../types'; // Re-export for convenience in command modules export { formatRelativeTime } from '../../utils/time'; @@ -26,6 +27,7 @@ export interface CodexAuthArgs { force?: boolean; shell?: string; unknownFlags?: string[]; + seenOptions?: string[]; } // ── Profile output shape (JSON mode) ───────────────────────────────────────── @@ -47,47 +49,32 @@ export interface CodexProfileOutput { // ── Name validation ─────────────────────────────────────────────────────────── -const RESERVED = new Set(['default', 'current']); - -/** - * Profile name must match /^[a-z0-9][a-z0-9_-]{0,63}$/ and not be reserved. - * Rejects uppercase, path separators, leading dash/underscore, length >64. - */ -export function isValidCodexProfileName(name: string): boolean { - if (!name || name.length > 64) return false; - if (RESERVED.has(name)) return false; - if (name.includes('/') || name.includes('\\')) return false; - return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name); -} - -export function getProfileNameError(name: string): string | null { - if (!name) return 'Profile name is required.'; - if (RESERVED.has(name)) return `Profile name "${name}" is reserved.`; - if (name.includes('/') || name.includes('\\')) - return 'Profile name must not contain path separators.'; - if (name.length > 64) return 'Profile name must be 64 characters or fewer.'; - if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) - return 'Profile name must match [a-z0-9][a-z0-9_-]{0,63}.'; - return null; -} +export { isValidCodexProfileName }; +export const getProfileNameError = getCodexProfileNameError; // ── Arg parsing ─────────────────────────────────────────────────────────────── export function parseArgs(args: string[]): CodexAuthArgs { - const result: CodexAuthArgs = { unknownFlags: [] }; + const result: CodexAuthArgs = { unknownFlags: [], seenOptions: [] }; const positional: string[] = []; + const markSeen = (flag: string) => result.seenOptions?.push(flag); for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--yes' || arg === '-y') { + markSeen('--yes'); result.yes = true; } else if (arg === '--json') { + markSeen('--json'); result.json = true; } else if (arg === '--force') { + markSeen('--force'); result.force = true; } else if (arg === '--shell') { - result.shell = args[++i]; + markSeen('--shell'); + result.shell = args[++i] ?? ''; } else if (arg.startsWith('--shell=')) { + markSeen('--shell'); result.shell = arg.slice('--shell='.length); } else if (arg.startsWith('-') && arg !== '--') { if (result.unknownFlags) result.unknownFlags.push(arg); @@ -103,9 +90,28 @@ export function parseArgs(args: string[]): CodexAuthArgs { return result; } -export function rejectUnsupportedOptions(parsed: CodexAuthArgs, usage: string): void { - if (parsed.unknownFlags && parsed.unknownFlags.length > 0) { +export interface AllowedCodexAuthOptions { + yes?: boolean; + json?: boolean; + force?: boolean; + shell?: boolean; +} + +export function rejectUnsupportedOptions( + parsed: CodexAuthArgs, + usage: string, + allowed: AllowedCodexAuthOptions = {} +): void { + const unsupported = new Set(parsed.unknownFlags ?? []); + const seen = new Set(parsed.seenOptions ?? []); + if (seen.has('--yes') && !allowed.yes) unsupported.add('--yes'); + if (seen.has('--json') && !allowed.json) unsupported.add('--json'); + if (seen.has('--force') && !allowed.force) unsupported.add('--force'); + if (seen.has('--shell') && !allowed.shell) unsupported.add('--shell'); + + if (unsupported.size > 0) { + const flags = [...unsupported].join(', '); process.stderr.write(`Usage: ${color(usage, 'command')}\n`); - exitWithError('Unknown options', ExitCode.GENERAL_ERROR); + exitWithError(`Unknown options: ${flags}`, ExitCode.GENERAL_ERROR); } } diff --git a/src/codex-auth/commands/use-command.ts b/src/codex-auth/commands/use-command.ts index 94a42979..a61e3464 100644 --- a/src/codex-auth/commands/use-command.ts +++ b/src/codex-auth/commands/use-command.ts @@ -36,7 +36,9 @@ const VALID_SHELLS = new Set(['bash', 'zsh', 'fish', 'pwsh', 'cmd']); export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise { const parsed = parseArgs(args); - rejectUnsupportedOptions(parsed, 'ccsx auth use [--shell ]'); + rejectUnsupportedOptions(parsed, 'ccsx auth use [--shell ]', { + shell: true, + }); const { profileName, shell: shellOverride } = parsed; diff --git a/src/codex-auth/resolve-active-profile.ts b/src/codex-auth/resolve-active-profile.ts index 65e7964f..555209ef 100644 --- a/src/codex-auth/resolve-active-profile.ts +++ b/src/codex-auth/resolve-active-profile.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths'; import { getCcsDirSource } from '../utils/config-manager'; +import { getCodexProfileNameError } from './types'; export interface ResolvedProfile { name: string; @@ -58,6 +59,47 @@ function resolutionFailure(message: string, envName: string, displayEnvName: str ); } +function assertValidProfileNameForResolution( + name: string, + envName: string, + displayEnvName: string +): void { + const nameError = getCodexProfileNameError(name); + if (nameError) { + resolutionFailure( + `profile name ${quoteDiagnosticValue(name)} is invalid: ${nameError}`, + envName, + displayEnvName + ); + } +} + +function assertValidProfileEntry( + name: string, + profiles: Record, + envName: string, + displayEnvName: string, + displayRegistryPath: string +): void { + assertValidProfileNameForResolution(name, envName, displayEnvName); + const profile = profiles[name]; + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + resolutionFailure( + `registry profile ${quoteDiagnosticValue(name)} at ${displayRegistryPath} is not a valid object`, + envName, + displayEnvName + ); + } + const type = (profile as { type?: unknown }).type; + if (type !== 'codex') { + resolutionFailure( + `registry profile ${quoteDiagnosticValue(name)} at ${displayRegistryPath} is not a Codex profile`, + envName, + displayEnvName + ); + } +} + /** @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(); @@ -98,14 +140,19 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso displayEnvName ); } + for (const profileName of Object.keys(profiles)) { + assertValidProfileEntry(profileName, profiles, envName, displayEnvName, displayRegistryPath); + } // F2: explicit env override if (envName) { + assertValidProfileNameForResolution(envName, envName, displayEnvName); if (!Object.prototype.hasOwnProperty.call(profiles, envName)) { throw new CodexAuthProfileResolutionError( `CCS_CODEX_PROFILE=${displayEnvName} not found in registry. Refusing to fall back to ~/.codex.` ); } + assertValidProfileEntry(envName, profiles, envName, displayEnvName, displayRegistryPath); return { name: envName, dir: path.resolve(resolveCodexProfileDir(envName)), @@ -115,7 +162,23 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso // F3: registry default const defaultName = registry.default ?? null; - if (defaultName && Object.prototype.hasOwnProperty.call(profiles, defaultName)) { + if (defaultName) { + if (typeof defaultName !== 'string') { + resolutionFailure( + `registry default at ${displayRegistryPath} is not a valid profile name`, + envName, + displayEnvName + ); + } + assertValidProfileNameForResolution(defaultName, envName, displayEnvName); + if (!Object.prototype.hasOwnProperty.call(profiles, defaultName)) { + resolutionFailure( + `registry default ${quoteDiagnosticValue(defaultName)} is missing from profiles map`, + envName, + displayEnvName + ); + } + assertValidProfileEntry(defaultName, profiles, envName, displayEnvName, displayRegistryPath); return { name: defaultName, dir: path.resolve(resolveCodexProfileDir(defaultName)), diff --git a/src/codex-auth/types.ts b/src/codex-auth/types.ts index 2280f553..8f283d1b 100644 --- a/src/codex-auth/types.ts +++ b/src/codex-auth/types.ts @@ -20,3 +20,27 @@ export interface CodexAccountIdentity { } export const CODEX_PROFILE_SCHEMA_VERSION = '1.0'; + +const RESERVED_CODEX_PROFILE_NAMES = new Set(['default', 'current']); + +/** + * Profile name must match /^[a-z0-9][a-z0-9_-]{0,63}$/ and not be reserved. + * Rejects uppercase, path separators, leading dash/underscore, length >64. + */ +export function isValidCodexProfileName(name: string): boolean { + if (!name || name.length > 64) return false; + if (RESERVED_CODEX_PROFILE_NAMES.has(name)) return false; + if (name.includes('/') || name.includes('\\')) return false; + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name); +} + +export function getCodexProfileNameError(name: string): string | null { + if (!name) return 'Profile name is required.'; + if (RESERVED_CODEX_PROFILE_NAMES.has(name)) return `Profile name "${name}" is reserved.`; + if (name.includes('/') || name.includes('\\')) + return 'Profile name must not contain path separators.'; + if (name.length > 64) return 'Profile name must be 64 characters or fewer.'; + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) + return 'Profile name must match [a-z0-9][a-z0-9_-]{0,63}.'; + return null; +} diff --git a/tests/unit/bin/ccsxp-runtime.test.ts b/tests/unit/bin/ccsxp-runtime.test.ts index 93c1e19d..f853f114 100644 --- a/tests/unit/bin/ccsxp-runtime.test.ts +++ b/tests/unit/bin/ccsxp-runtime.test.ts @@ -7,9 +7,10 @@ const ccsPath = require.resolve('../../../src/ccs.ts'); describe('ccsxp runtime wrapper', () => { const originalArgv = process.argv; - const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; - const originalCodexHome = process.env.CODEX_HOME; - const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; + const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; + const originalCodexHome = process.env.CODEX_HOME; + const originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE; + const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; beforeEach(() => { delete require.cache[wrapperPath]; @@ -29,6 +30,11 @@ describe('ccsxp runtime wrapper', () => { } else { process.env.CODEX_HOME = originalCodexHome; } + if (originalCcsCodexProfile === undefined) { + delete process.env.CCS_CODEX_PROFILE; + } else { + process.env.CCS_CODEX_PROFILE = originalCcsCodexProfile; + } if (originalCcsxpCodexHome === undefined) { delete process.env.CCSXP_CODEX_HOME; } else { @@ -75,6 +81,27 @@ describe('ccsxp runtime wrapper', () => { expect(process.env.CODEX_HOME).toBe('/tmp/explicit-ccsxp-codex-home'); }); + it('emits a notice when CCS_CODEX_PROFILE is ignored by ccsxp', () => { + process.env.CCS_CODEX_PROFILE = 'work'; + process.argv = ['node', wrapperPath, '--version']; + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const stderrChunks: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array): boolean => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }; + try { + require(wrapperPath); + } finally { + process.stderr.write = origWrite; + } + + expect(process.env.CODEX_HOME).toBe(path.join(os.homedir(), '.codex')); + expect(stderrChunks.join('')).toContain('CCS_CODEX_PROFILE is ignored by ccsxp'); + }); + it('keeps flag-only invocations routed through the native cliproxy shortcut', () => { process.argv = ['node', wrapperPath, '--version']; require.cache[ccsPath] = { exports: {} } as NodeJS.Module; diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts index 33091b9e..5aadcd02 100644 --- a/tests/unit/bin/codex-runtime-router.test.ts +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -318,6 +318,31 @@ describe('codex-runtime router — non-auth profile resolution', () => { expect(process.env.CODEX_HOME).toBe(explicitHome); }); + it('lets CCS_CODEX_PROFILE override a stale explicit CODEX_HOME', async () => { + const explicitHome = path.join(tempDir, 'stale-codex-home'); + const profileDir = makeProfileDir('work'); + fs.mkdirSync(explicitHome, { recursive: true }); + process.env.CODEX_HOME = explicitHome; + process.env.CCS_CODEX_PROFILE = 'work'; + writeRegistry({ + version: '1.0', + default: null, + profiles: { work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } }, + }); + + const { 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(process.env.CODEX_HOME).toBe(profileDir); + expect(stderr).toContain('overrides existing CODEX_HOME'); + }); + it('sets CODEX_HOME from registry default when no CCS_CODEX_PROFILE is set', async () => { const profileDir = makeProfileDir('personal'); writeRegistry({ diff --git a/tests/unit/codex-auth/codex-config-symlink.test.ts b/tests/unit/codex-auth/codex-config-symlink.test.ts index b8936846..fc94a188 100644 --- a/tests/unit/codex-auth/codex-config-symlink.test.ts +++ b/tests/unit/codex-auth/codex-config-symlink.test.ts @@ -66,9 +66,10 @@ describe('ensureSharedConfigSymlink', () => { expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); }); - it('replaces a regular file at link path with symlink (with warning)', () => { + it('preserves an edited regular file at link path by default', () => { fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); const linkPath = path.join(profileDir, 'config.toml'); + fs.writeFileSync(sharedConfigPath, '[shared]\ndata = true', { mode: 0o600 }); fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 }); // Capture stderr to verify warning was written @@ -85,10 +86,21 @@ describe('ensureSharedConfigSymlink', () => { process.stderr.write = origWrite; } + expect(fs.lstatSync(linkPath).isFile()).toBe(true); + expect(fs.readFileSync(linkPath, 'utf8')).toBe('[existing]\ndata = true'); + // A warning should have been emitted + expect(stderrChunks.join('')).toMatch(/preserving existing regular config/i); + }); + + it('replaces a regular file when explicit overwrite repair is requested', () => { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + const linkPath = path.join(profileDir, 'config.toml'); + fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 }); + + ensureSharedConfigSymlink(profileDir, sharedConfigPath, { overwriteRegularFile: true }); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath); - // A warning should have been emitted - expect(stderrChunks.join('')).toMatch(/overwr|replaced|regular file/i); }); it('replaces a broken symlink (dangling) with correct symlink', () => { @@ -130,4 +142,26 @@ describe('ensureSharedConfigSymlink', () => { expect(fs.readFileSync(linkPath, 'utf8')).toBe('model = "gpt-5.5"\n'); expect(stderrChunks.join('')).toContain('symlink unavailable'); }); + + it('preserves edited fallback copies on later repair attempts', () => { + fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 }); + const linkPath = path.join(profileDir, 'config.toml'); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' }); + }); + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + + try { + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + fs.writeFileSync(linkPath, 'model = "local-edit"\n', { mode: 0o600 }); + ensureSharedConfigSymlink(profileDir, sharedConfigPath); + } finally { + process.stderr.write = origWrite; + symlinkSpy.mockRestore(); + } + + expect(fs.lstatSync(linkPath).isFile()).toBe(true); + expect(fs.readFileSync(linkPath, 'utf8')).toBe('model = "local-edit"\n'); + }); }); diff --git a/tests/unit/codex-auth/codex-profile-registry.test.ts b/tests/unit/codex-auth/codex-profile-registry.test.ts index ed05c0f1..03b22517 100644 --- a/tests/unit/codex-auth/codex-profile-registry.test.ts +++ b/tests/unit/codex-auth/codex-profile-registry.test.ts @@ -97,6 +97,13 @@ describe('CodexProfileRegistry — create and get', () => { reg.createProfile('work'); expect(reg.hasProfile('work')).toBe(true); }); + + it('rejects unsafe profile names before writing the registry', () => { + const reg = new CodexProfileRegistry(registryPath); + expect(() => reg.createProfile('../escape')).toThrow(/path separators/i); + expect(reg.hasProfile('../escape')).toBe(false); + expect(fs.existsSync(registryPath)).toBe(false); + }); }); describe('CodexProfileRegistry — remove', () => { @@ -175,6 +182,45 @@ describe('CodexProfileRegistry — corrupt YAML safety', () => { expect(() => reg.createProfile('work')).toThrow(/profiles map/i); expect(fs.readFileSync(registryPath, 'utf8')).toBe(invalidShape); }); + + it('refuses registry entries with unsafe profile names', () => { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + const unsafeRegistry = + 'version: "1.0"\ndefault: null\nprofiles:\n ../escape:\n type: codex\n created: "2026-01-01T00:00:00.000Z"\n last_used: null\n'; + fs.writeFileSync(registryPath, unsafeRegistry, { mode: 0o600 }); + const reg = new CodexProfileRegistry(registryPath); + + expect(() => reg.listProfiles()).toThrow(/invalid profile name/i); + expect(fs.readFileSync(registryPath, 'utf8')).toBe(unsafeRegistry); + }); + + it('refuses malformed profile entries instead of activating corrupt state', () => { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + const malformedRegistry = 'version: "1.0"\ndefault: work\nprofiles:\n work: 1\n'; + fs.writeFileSync(registryPath, malformedRegistry, { mode: 0o600 }); + const reg = new CodexProfileRegistry(registryPath); + + expect(() => reg.getDefault()).toThrow(/must be an object/i); + expect(fs.readFileSync(registryPath, 'utf8')).toBe(malformedRegistry); + }); + + it('redacts absolute registry paths and raw YAML parser details in read errors', () => { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + const corrupt = '{ invalid yaml: [[[ sensitive-local-fragment'; + fs.writeFileSync(registryPath, corrupt, { mode: 0o600 }); + const reg = new CodexProfileRegistry(registryPath); + + let message = ''; + try { + reg.listProfiles(); + } catch (err) { + message = String(err); + } + + expect(message).toContain('$CCS_HOME/.ccs/codex-profiles.yaml'); + expect(message).not.toContain(registryPath); + expect(message).not.toContain('sensitive-local-fragment'); + }); }); describe('CodexProfileRegistry — atomic write', () => { diff --git a/tests/unit/codex-auth/commands/create-command.test.ts b/tests/unit/codex-auth/commands/create-command.test.ts index 49b83ccc..4f58df78 100644 --- a/tests/unit/codex-auth/commands/create-command.test.ts +++ b/tests/unit/codex-auth/commands/create-command.test.ts @@ -91,7 +91,7 @@ describe('handleCreateCodex — happy path', () => { }); describe('handleCreateCodex — idempotent re-run', () => { - it('no-op when profile already exists (no --force)', async () => { + it('repairs config.toml and preserves auth.json when profile already exists (no --force)', async () => { const detectorMod = await import('../../../../src/targets/codex-detector'); spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); @@ -103,12 +103,28 @@ describe('handleCreateCodex — idempotent re-run', () => { const restore = silenceConsole(); try { await handleCreateCodex(ctx, ['dupprofile']); - await handleCreateCodex(ctx, ['dupprofile']); // second call is idempotent } finally { restore(); } + + const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'dupprofile'); + const configPath = path.join(profileDir, 'config.toml'); + const authJsonPath = path.join(profileDir, 'auth.json'); + const authJson = JSON.stringify({ tokens: { id_token: buildToken({ email: 'idempotent@test' }) } }); + fs.writeFileSync(authJsonPath, authJson); + fs.rmSync(configPath, { force: true }); + + const restore2 = silenceConsole(); + try { + await handleCreateCodex(ctx, ['dupprofile']); // second call is idempotent and self-healing + } finally { + restore2(); + } + // Profile still has exactly one entry expect(ctx.registry.listProfiles().filter((n) => n === 'dupprofile').length).toBe(1); + expect(fs.existsSync(configPath)).toBe(true); + expect(fs.readFileSync(authJsonPath, 'utf8')).toBe(authJson); }); }); @@ -233,6 +249,35 @@ describe('handleCreateCodex — validation', () => { expect(exitCalled).toBe(true); }); + + it('rejects command-specific flags that create does not support', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-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 handleCreateCodex(ctx, ['flagleak', '--shell', 'fish']); + } catch { + /* expected */ + } finally { + restore(); + process.exit = origExit; + } + + expect(exitCalled).toBe(true); + expect(ctx.registry.hasProfile('flagleak')).toBe(false); + }); }); describe('handleCreateCodex — auto-spawn login (D11)', () => { @@ -300,17 +345,27 @@ describe('handleCreateCodex — auto-spawn login (D11)', () => { '../../../../src/codex-auth/commands/create-command' ); const ctx = await makeCtx(); + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; const restore = silenceConsole(); try { await handleCreateCodex(ctx, ['faillogin']); + } catch { + /* expected */ } finally { restore(); + process.exit = origExit; } const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'faillogin'); expect(fs.existsSync(profileDir)).toBe(true); expect(ctx.registry.hasProfile('faillogin')).toBe(true); + expect(exitCode).toBe(4); }); it('persists last_used and account_id when login token has account_id only', 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 98c7afa4..78107bbe 100644 --- a/tests/unit/codex-auth/commands/import-default-command.test.ts +++ b/tests/unit/codex-auth/commands/import-default-command.test.ts @@ -108,7 +108,11 @@ function captureOutput(): { stderr: string[]; restore: () => void } { const stderr: string[] = []; const origStdErr = process.stderr.write.bind(process.stderr); const origLog = console.log; + const origErr = console.error; console.log = () => {}; + console.error = (...args: unknown[]) => { + stderr.push(args.join(' ')); + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any process.stderr.write = (chunk: any) => { stderr.push(String(chunk)); @@ -118,6 +122,7 @@ function captureOutput(): { stderr: string[]; restore: () => void } { stderr, restore: () => { console.log = origLog; + console.error = origErr; process.stderr.write = origStdErr; }, }; @@ -192,6 +197,56 @@ describe('import-default — missing legacy auth.json', () => { }); }); +describe('import-default — option validation', () => { + it('rejects unsupported flags before importing legacy auth', async () => { + fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); + + const { handleImportDefaultCodex } = await import( + '../../../../src/codex-auth/commands/import-default-command' + ); + const ctx = await makeCtx(); + + let exitCount = 0; + const origExit = process.exit; + process.exit = () => { + exitCount++; + throw new Error('exit'); + }; + const captured = captureOutput(); + try { + await handleImportDefaultCodex(ctx, ['typo', '--with-historyy']); + } catch { + /* expected */ + } + try { + await handleImportDefaultCodex(ctx, ['shellleak', '--shell', 'fish']); + } catch { + /* expected */ + } + try { + await handleImportDefaultCodex(ctx, ['jsonleak', '--json']); + } catch { + /* expected */ + } + try { + await handleImportDefaultCodex(ctx, ['yesleak', '--yes']); + } catch { + /* expected */ + } finally { + captured.restore(); + process.exit = origExit; + } + + expect(exitCount).toBe(4); + expect(ctx.registry.hasProfile('typo')).toBe(false); + expect(ctx.registry.hasProfile('shellleak')).toBe(false); + expect(ctx.registry.hasProfile('jsonleak')).toBe(false); + expect(ctx.registry.hasProfile('yesleak')).toBe(false); + expect(captured.stderr.join('')).toContain('Usage:'); + expect(captured.stderr.join('')).toContain('--shell'); + }); +}); + describe('import-default — profile collision without --force', () => { it('refuses when profile exists without --force', async () => { // Write valid legacy auth diff --git a/tests/unit/codex-auth/commands/login-command.test.ts b/tests/unit/codex-auth/commands/login-command.test.ts index a650c2f0..11782428 100644 --- a/tests/unit/codex-auth/commands/login-command.test.ts +++ b/tests/unit/codex-auth/commands/login-command.test.ts @@ -104,6 +104,67 @@ describe('handleLoginCodex — missing profile auto-creates', () => { expect(ctx.registry.hasProfile('newprofile')).toBe(true); expect(out.some((l) => l.includes('Auto-creating'))).toBe(true); }); + + it('does not create an orphan registry entry when profile dir setup fails', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex'); + + const originalMkdirSync = fs.mkdirSync; + spyOn(fs, 'mkdirSync').mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target: fs.PathLike, options?: any): string | undefined => { + if (String(target).includes('codex-instances')) { + throw Object.assign(new Error('simulated mkdir failure'), { code: 'EACCES' }); + } + return originalMkdirSync(target, options); + } + ); + + const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command'); + const ctx = await makeCtx(); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; + const origLog = console.log; + console.log = () => {}; + try { + await handleLoginCodex(ctx, ['orphan']); + } catch { + /* expected */ + } finally { + console.log = origLog; + process.exit = origExit; + } + + expect(exitCode).toBe(2); + expect(ctx.registry.hasProfile('orphan')).toBe(false); + }); + + it('rejects command-specific flags that login does not support', async () => { + const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command'); + const ctx = await makeCtx(); + + let exitCalled = false; + const origExit = process.exit; + process.exit = () => { + exitCalled = true; + throw new Error('exit'); + }; + try { + await handleLoginCodex(ctx, ['flagleak', '--json']); + } catch { + /* expected */ + } finally { + process.exit = origExit; + } + + expect(exitCalled).toBe(true); + expect(ctx.registry.hasProfile('flagleak')).toBe(false); + }); }); describe('handleLoginCodex — spawn called with CODEX_HOME pinned', () => { diff --git a/tests/unit/codex-auth/resolve-active-profile.test.ts b/tests/unit/codex-auth/resolve-active-profile.test.ts index 85493949..153f479e 100644 --- a/tests/unit/codex-auth/resolve-active-profile.test.ts +++ b/tests/unit/codex-auth/resolve-active-profile.test.ts @@ -98,6 +98,52 @@ describe('resolveActiveProfile', () => { expect(() => resolveActiveProfile({})).toThrow(/valid profiles map/); }); + it('throws when CCS_CODEX_PROFILE contains an unsafe profile name', () => { + writeRegistry({ + version: '1.0', + default: null, + profiles: {}, + }); + + expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: '../escape' })).toThrow( + /invalid.*path separators/i + ); + }); + + it('throws when registry default contains an unsafe profile name', () => { + writeRegistry({ + version: '1.0', + default: '../escape', + profiles: { + '../escape': { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null }, + }, + }); + + expect(() => resolveActiveProfile({})).toThrow(/invalid.*path separators/i); + }); + + it('throws when registry default points to a missing profile', () => { + writeRegistry({ + version: '1.0', + default: 'ghost', + profiles: {}, + }); + + expect(() => resolveActiveProfile({})).toThrow(/default 'ghost' is missing/i); + }); + + it('throws when matched registry profile entry is malformed', () => { + writeRegistry({ + version: '1.0', + default: 'work', + profiles: { + work: 1, + }, + }); + + expect(() => resolveActiveProfile({})).toThrow(/not a valid object/i); + }); + it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => { const profileDir = makeProfileDir('work'); writeRegistry({ diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js index 540fa1c7..bbb9acee 100644 --- a/tests/unit/scripts/run-test-bucket.test.js +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -28,6 +28,12 @@ describe('run-test-bucket', () => { } }); + test('keeps web-server integration tests that bind ports in the slow bucket', () => { + const slowSet = bucket.getSlowSet(); + + expect(slowSet.has('tests/integration/web-server/codex-profiles-endpoint.test.ts')).toBe(true); + }); + test('forces npm tests into the slow bucket', () => { expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true); });