mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(codex-auth): close local review gaps
This commit is contained in:
+5
-2
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user