fix(codex-auth): close local review gaps

This commit is contained in:
Tam Nhu Tran
2026-05-17 17:41:23 -04:00
parent 2cd2d43186
commit 85521018bf
24 changed files with 719 additions and 109 deletions
+5 -2
View File
@@ -50,7 +50,7 @@ codex # runs with CODEX_HOME=~/.ccs/codex-instances/pers
|---------|-------------|
| `ccsx auth create <name>` | Create profile dir + auto-login |
| `ccsx auth login <name>` | (Re-)authenticate an existing profile |
| `ccsx auth switch <name>` | Set the persistent default profile (all new shells) |
| `ccsx auth switch <name>` | Set the persistent default profile for future `ccsx` launches |
| `ccsx auth use <name>` | 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 <name>` | Delete profile dir + registry entry |
@@ -60,9 +60,12 @@ codex # runs with CODEX_HOME=~/.ccs/codex-instances/pers
| Method | Scope | How |
|--------|-------|-----|
| `ccsx auth switch <name>` | All future shells | Writes to `~/.ccs/codex-profiles.yaml` |
| `ccsx auth switch <name>` | Future `ccsx` launches | Writes to `~/.ccs/codex-profiles.yaml` |
| `eval "$(ccsx auth use <name>)"` | 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
+1
View File
@@ -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',
+8 -2
View File
@@ -54,9 +54,10 @@ export async function main(argv: string[]): Promise<number> {
// ── 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<number> {
};
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 {
+32 -3
View File
@@ -5,6 +5,10 @@ import { getSharedCodexConfigPath } from './codex-profile-paths';
const logger = createLogger('codex-auth:symlink');
export interface EnsureSharedConfigSymlinkOptions {
overwriteRegularFile?: boolean;
}
/**
* Ensure <profileDir>/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 <name> --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);
+13 -1
View File
@@ -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,
+94 -5
View File
@@ -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<string, CodexProfileMetadata> = {};
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<string, CodexProfileMetadata>,
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<CodexProfileMetadata>;
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<CodexProfileMetadata> = {}): 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<CodexProfileMetadata>): 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]) {
+43 -35
View File
@@ -23,7 +23,7 @@ const logger = createLogger('codex-auth:cmd:create');
export async function handleCreateCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth create <name> [--force]');
rejectUnsupportedOptions(parsed, 'ccsx auth create <name> [--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<void>((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);
}
}
@@ -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 <name> [--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 <name> [--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)
+17 -15
View File
@@ -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);
}
}
+4 -1
View File
@@ -21,7 +21,10 @@ import type { CodexProfileMetadata } from '../types';
export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth remove <name> [--yes|-y] [--force]');
rejectUnsupportedOptions(parsed, 'ccsx auth remove <name> [--yes|-y] [--force]', {
yes: true,
force: true,
});
const { profileName, yes, force } = parsed;
+1 -1
View File
@@ -19,7 +19,7 @@ import type { CodexAccountIdentity } from '../types';
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
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;
+34 -28
View File
@@ -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);
}
}
+3 -1
View File
@@ -36,7 +36,9 @@ const VALID_SHELLS = new Set<string>(['bash', 'zsh', 'fish', 'pwsh', 'cmd']);
export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]');
rejectUnsupportedOptions(parsed, 'ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]', {
shell: true,
});
const { profileName, shell: shellOverride } = parsed;
+64 -1
View File
@@ -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<string, unknown>,
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)),
+24
View File
@@ -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;
}
+30 -3
View File
@@ -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;
@@ -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<number> };
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({
@@ -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');
});
});
@@ -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', () => {
@@ -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 () => {
@@ -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
@@ -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', () => {
@@ -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({
@@ -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);
});