feat(codex-auth): add import-default migration + integration tests + docs

Adds opt-in `ccsx auth import-default <name>` to migrate the existing
~/.codex/auth.json into a new profile, plus the cross-system integration
tests and user-facing documentation.

- import-default-command (C3 torn-write protection):
  - readFileSync + JSON.parse with 3x retry / 100ms backoff to survive
    Codex's truncate-then-write auth.json refresh race
  - decode-id-token sanity-check on JWT shape (catches mid-write JWT
    corruption that JSON.parse alone wouldn't notice)
  - pgrep -f codex best-effort detection; warns + refuses without
    --force-while-running flag if a live codex process is found
  - rejects cliproxy-format auth files ({type: "codex", ...} wrapper)
    with a clear "use ccs cliproxy ..." pointer
  - atomic write to <dest>.tmp.<pid>.<rand> + rename
  - --with-history defaults to false per D8 (auth-only is the safer
    default; opt in for bulkier data)
  - --force backs up existing auth.json to .bak-<ts> before overwrite
  - non-destructive — never modifies ~/.codex/; legacy mode keeps
    working without ever running this command
- integration tests:
  - two-terminal-isolation: two profiles with separate CODEX_HOMEs
    write to their own auth.json/history.jsonl with no crosstalk
  - ccsxp-independence: codex-auth profile set; ccsxp still uses its
    own CCSXP_CODEX_HOME / ~/.codex pool (H5 stderr notice present)
  - legacy-fallback: no profiles registered → codex-runtime-router
    leaves CODEX_HOME unset → codex falls back to ~/.codex
  - import-default.integration: real fs copy + decode + register
- docs/codex-auth.md: user guide covering quick start, two-terminal
  example, migration, dashboard, and caveats (cmd.exe, Windows
  symlinks, ccsx vs ccsxp distinction)

155 codex-auth-scope tests green (45 Phase 1 + 57 Phase 2 + 19 Phase 3
+ 15 Phase 4 + 19 Phase 5). Full suite 3051/3082 — the 1 failure is a
pre-existing test-pollution issue between ccsxp-runtime.test.ts and
codex-runtime-integration.test.ts that exists on dev today; the test
passes in isolation.
This commit is contained in:
Tam Nhu Tran
2026-05-17 14:46:16 -04:00
parent e99c4612a8
commit 631c799322
7 changed files with 1582 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
# Codex Auth Profile Isolation (`ccsx auth`)
Run two Codex accounts simultaneously — one per terminal — with full auth isolation.
## Why
Codex stores its OAuth credentials in a single directory (`~/.codex/`). When you run two
`codex` sessions in separate terminals, they both write to the same `auth.json`. A token
refresh in one session overwrites the other's credentials.
`ccsx auth` solves this by giving each account its own profile directory under
`~/.ccs/codex-instances/<name>/`. Each profile holds its own `auth.json` and
`history.jsonl`. A shared `config.toml` is linked via symlink so model/provider settings
stay in sync.
## Quick start (4 commands)
```bash
# Create and authenticate two profiles
ccsx auth create work # creates ~/.ccs/codex-instances/work/ and prompts for login
ccsx auth create personal # same for personal account
# Activate per terminal (ephemeral — only this shell)
# Terminal A:
eval "$(ccsx auth use work)"
codex
# Terminal B:
eval "$(ccsx auth use personal)"
codex
```
## Two-terminal example
```bash
# Terminal A — work account
eval "$(ccsx auth use work)"
codex # runs with CODEX_HOME=~/.ccs/codex-instances/work
# Terminal B — personal account (simultaneously)
eval "$(ccsx auth use personal)"
codex # runs with CODEX_HOME=~/.ccs/codex-instances/personal
# No token clobbering. Each session refreshes its own auth.json only.
```
## Command reference
| Command | Description |
|---------|-------------|
| `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 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 |
| `ccsx auth import-default <name>` | Migrate legacy `~/.codex/auth.json` into a new profile |
## Persistent vs ephemeral switching
| Method | Scope | How |
|--------|-------|-----|
| `ccsx auth switch <name>` | All future shells | Writes to `~/.ccs/codex-profiles.yaml` |
| `eval "$(ccsx auth use <name>)"` | Current shell only | Sets `CODEX_HOME` + `CCS_CODEX_PROFILE` in your shell |
Shell syntax for `use`:
```bash
# bash / zsh
eval "$(ccsx auth use work)"
# fish
ccsx auth use work | source
# PowerShell
ccsx auth use work | Invoke-Expression
```
## Migration from `~/.codex`
If you already have a logged-in session in `~/.codex/auth.json`, import it without
disturbing the original:
```bash
# Auth only (default — recommended)
ccsx auth import-default legacy
# Auth + history + sessions (opt-in)
ccsx auth import-default legacy --with-history
# Make it the default
ccsx auth switch legacy
```
The source `~/.codex/` directory is **never modified**. If `import-default` is not run,
`codex` continues to work exactly as before.
### Torn-write safety
Codex writes `auth.json` with truncate+write (not atomic rename). Running
`import-default` while a token refresh is in flight can produce a corrupt copy.
The command detects a running `codex` process via `pgrep` and refuses unless you
pass `--force-while-running`. The safest approach is to quit Codex before
importing.
## Dashboard
The CCS dashboard shows active profile metadata at the **Auth Profiles** tab on the
Codex page:
- Profile name and whether it is the current default
- Decoded email address (from `id_token` — no signature verification; display only)
- Plan tier (Plus, Pro, Free) when present in the token
- Last-used timestamp
No OAuth tokens are ever returned by the API endpoint or shown in the UI.
## Profile disk layout
```
~/.ccs/
├── codex-profiles.yaml # Registry: version, default, profiles metadata
└── codex-instances/
└── <name>/
├── auth.json # OAuth credentials (Codex writes here)
├── history.jsonl # Per-profile prompt history (optional)
├── sessions/ # Per-profile chat session dirs (optional)
└── config.toml -> ~/.codex/config.toml (symlink — shared)
~/.codex/
└── config.toml # Single shared model/provider config
```
## Caveats
### Windows symlinks
On Windows, creating symlinks requires Developer Mode or elevated privileges.
If symlink creation fails, CCS falls back to copying `config.toml`. In this case,
changes to `~/.codex/config.toml` are **not** automatically reflected in the profile —
you must re-run `ccsx auth create <name> --force` to refresh the copy.
### `ccsx` vs `ccsxp`
`ccsx auth` profiles apply only to the **native `codex`** CLI. They have no effect on
`ccsxp` (the CLIProxy round-robin pool). `ccsxp` unconditionally sets its own
`CODEX_HOME` on startup and ignores `CCS_CODEX_PROFILE`.
If you run `eval "$(ccsx auth use work)"` and then invoke `ccsxp`, a notice is emitted
to stderr:
```
[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only
```
### cmd.exe
`ccsx auth use` emits `set FOO=bar` syntax for cmd.exe. Native `eval` is not available
in legacy cmd — use PowerShell (`Invoke-Expression`) instead.
### Backup files from `--force`
When re-importing with `--force`, the existing `auth.json` is backed up as
`auth.json.bak-<timestamp>` in the profile directory. These accumulate over time; remove
them manually when no longer needed.
@@ -0,0 +1,355 @@
/**
* codex-auth import-default command.
*
* Migrates legacy ~/.codex/auth.json into a named profile (non-destructive).
* Implements C3 torn-write protection: read-with-retry + JWT-shape validation
* + pgrep Codex-running detection + atomic write.
*
* Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { ok } from '../../utils/ui';
import { initUI } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink, decodeIdToken } from '../index';
import { parseArgs, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:import-default');
// Maximum retries for torn-write detection (C3)
const MAX_READ_RETRIES = 3;
const RETRY_DELAY_MS = 100;
// CLIProxy format marker (reject these with a clear message)
const CLIPROXY_TYPE_MARKER = 'type';
// ── helpers ──────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Detect a running `codex` process via pgrep (best-effort, never throws).
* Returns the PID string if found, null otherwise.
*/
function detectCodexRunning(): string | null {
try {
const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], {
encoding: 'utf8',
timeout: 2000,
});
if (result.status === 0 && result.stdout && result.stdout.trim().length > 0) {
return result.stdout.trim().split('\n')[0];
}
return null;
} catch {
return null;
}
}
/**
* Read and validate auth.json with retry on torn-write (C3).
* Returns parsed auth JSON or throws on persistent failure.
*/
async function readAuthJsonSafe(authSrcPath: string): Promise<Record<string, unknown>> {
let lastErr: Error | null = null;
for (let attempt = 0; attempt < MAX_READ_RETRIES; attempt++) {
try {
const buf = fs.readFileSync(authSrcPath, 'utf8');
const parsed = JSON.parse(buf) as Record<string, unknown>;
// Reject cliproxy-format auth files
if (
typeof parsed[CLIPROXY_TYPE_MARKER] === 'string' &&
(parsed[CLIPROXY_TYPE_MARKER] === 'codex' ||
parsed[CLIPROXY_TYPE_MARKER] === 'anthropic' ||
parsed[CLIPROXY_TYPE_MARKER] === 'gemini')
) {
throw new Error(
`CLIPROXY_FORMAT: Source is a CLIProxy auth file (type="${parsed[CLIPROXY_TYPE_MARKER]}"); use \`ccs cliproxy ...\` instead.`
);
}
// Validate JWT shape: must have tokens.id_token as a 3-segment JWT
const tokens = parsed['tokens'] as Record<string, unknown> | undefined;
if (tokens) {
const idToken = tokens['id_token'];
if (typeof idToken === 'string' && idToken.length > 0) {
const parts = idToken.split('.');
if (parts.length < 3) {
// Torn write mid-JWT — retry
throw new Error(`TORN_JWT: id_token has ${parts.length} segments (need 3)`);
}
// Attempt decode to verify shape is sane
const identity = decodeIdToken(idToken);
// If all fields empty but token has 3 segments, it might be valid (no email claim)
void identity;
}
}
return parsed;
} catch (err) {
if (err instanceof Error && err.message.startsWith('CLIPROXY_FORMAT:')) {
throw err; // never retry CLIProxy format rejection
}
lastErr = err instanceof Error ? err : new Error(String(err));
logger.warn(
'codex-auth.import-default.torn-read',
`Read attempt ${attempt + 1}/${MAX_READ_RETRIES} failed: ${lastErr.message}`
);
if (attempt < MAX_READ_RETRIES - 1) {
await sleep(RETRY_DELAY_MS);
}
}
}
throw new Error(
`Failed to read a valid auth.json after ${MAX_READ_RETRIES} attempts: ${lastErr?.message ?? 'unknown'}`
);
}
/**
* Atomic copy: write to tmp.<pid>.<rand>, fsync, rename to dest.
* Preserves 0600 permissions.
*/
function atomicWriteFile(dest: string, content: string): void {
const tmpPath = `${dest}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(tmpPath, content, { mode: 0o600 });
// fsync via close-and-reopen pattern (Bun/Node doesn't expose fd fsync easily)
const fd = fs.openSync(tmpPath, 'r');
fs.fsyncSync(fd);
fs.closeSync(fd);
fs.renameSync(tmpPath, dest);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort cleanup
}
throw err;
}
}
/**
* Copy a file if it exists; silently skip if not.
* Returns 'copied' | 'missing'.
*/
function copyIfPresent(src: string, dest: string): 'copied' | 'missing' {
if (!fs.existsSync(src)) return 'missing';
const content = fs.readFileSync(src, 'utf8');
atomicWriteFile(dest, content);
return 'copied';
}
/**
* Recursively copy a directory if it exists.
* Returns count of files copied, or -1 if dir missing.
*/
function copyDirIfPresent(srcDir: string, destDir: string): number {
if (!fs.existsSync(srcDir)) return -1;
fs.mkdirSync(destDir, { recursive: true, mode: 0o700 });
let count = 0;
for (const entry of fs.readdirSync(srcDir)) {
const srcEntry = path.join(srcDir, entry);
const destEntry = path.join(destDir, entry);
const stat = fs.lstatSync(srcEntry);
if (stat.isDirectory()) {
count += copyDirIfPresent(srcEntry, destEntry);
} else if (stat.isFile()) {
fs.copyFileSync(srcEntry, destEntry);
count++;
}
}
return count;
}
// ── main command ──────────────────────────────────────────────────────────────
export interface ImportDefaultArgs {
name: string;
withHistory: boolean;
force: boolean;
forceWhileRunning: boolean;
}
function parseImportDefaultArgs(rawArgs: string[]): ImportDefaultArgs | null {
// --with-history, --force, --force-while-running are distinct flags
const parsed = parseArgs(rawArgs);
const withHistory = rawArgs.includes('--with-history');
const forceWhileRunning = rawArgs.includes('--force-while-running');
if (!parsed.profileName) return null;
return {
name: parsed.profileName,
withHistory,
force: parsed.force ?? false,
forceWhileRunning,
};
}
export async function handleImportDefaultCodex(
ctx: CodexCommandContext,
rawArgs: string[]
): Promise<void> {
await initUI();
const args = parseImportDefaultArgs(rawArgs);
if (!args) {
console.log(
`Usage: ccsx auth import-default <name> [--with-history] [--force] [--force-while-running]`
);
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(args.name);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
// Resolve legacy Codex home — LEGACY_CODEX_HOME env allows test hermeticity (D decision)
const legacyCodexHome = process.env['LEGACY_CODEX_HOME'] ?? path.join(os.homedir(), '.codex');
const legacyAuthPath = path.join(legacyCodexHome, 'auth.json');
// Check legacy auth.json exists
if (!fs.existsSync(legacyAuthPath)) {
console.log(` Use \`ccsx auth login ${args.name}\` to authenticate a new profile instead.`);
exitWithError('No legacy auth.json', ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
// Profile collision check
if (registry.hasProfile(args.name) && !args.force) {
console.log(` Use --force to overwrite (a .bak-<ts> backup will be created).`);
exitWithError('Profile already exists', ExitCode.PROFILE_ERROR);
return;
}
// Detect Codex running (C3)
const codexPid = detectCodexRunning();
if (codexPid && !args.forceWhileRunning) {
process.stderr.write(
`[!] Codex appears to be running (PID ${codexPid}). A token refresh may be in flight.\n`
);
process.stderr.write(
` Quit Codex first, then re-run import-default. Or pass --force-while-running to proceed anyway.\n`
);
exitWithError('Codex is running', ExitCode.PROFILE_ERROR);
return;
}
if (codexPid && args.forceWhileRunning) {
process.stderr.write(
`[!] Proceeding with Codex running (--force-while-running). Be aware a refresh may race.\n`
);
}
// Read + validate source (C3 torn-write protection)
let authData: Record<string, unknown>;
try {
authData = await readAuthJsonSafe(legacyAuthPath);
} catch (err) {
if (err instanceof Error && err.message.startsWith('CLIPROXY_FORMAT:')) {
exitWithError(err.message, ExitCode.PROFILE_ERROR);
return;
}
const msg = err instanceof Error ? err.message : String(err);
exitWithError(msg, ExitCode.GENERAL_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(args.name);
// Create dir
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
const destAuthPath = path.join(profileDir, 'auth.json');
// Backup existing auth.json if --force overwrite
if (args.force && fs.existsSync(destAuthPath)) {
const bakPath = `${destAuthPath}.bak-${Date.now()}`;
fs.copyFileSync(destAuthPath, bakPath);
process.stderr.write(`[i] Backed up existing auth.json to ${path.basename(bakPath)}\n`);
}
// Atomic write (C3)
atomicWriteFile(destAuthPath, JSON.stringify(authData, null, 2));
logger.stage('dispatch', 'codex-auth.import-default.copied', 'Copied auth.json to profile', {
name: args.name,
dest: destAuthPath,
});
// Optional: copy history + sessions (D8 default false)
let historyStatus: string;
let sessionsStatus: string;
if (args.withHistory) {
const legacyHistoryPath = path.join(legacyCodexHome, 'history.jsonl');
const destHistoryPath = path.join(profileDir, 'history.jsonl');
const historyCopied = copyIfPresent(legacyHistoryPath, destHistoryPath);
historyStatus = historyCopied === 'copied' ? 'copied' : 'not present';
const legacySessionsDir = path.join(legacyCodexHome, 'sessions');
const destSessionsDir = path.join(profileDir, 'sessions');
const sessionCount = copyDirIfPresent(legacySessionsDir, destSessionsDir);
sessionsStatus = sessionCount >= 0 ? `copied ${sessionCount} files` : 'not present';
} else {
historyStatus = 'not requested';
sessionsStatus = 'not requested';
}
// Ensure shared config symlink (reuse Phase 1 helper)
try {
ensureSharedConfigSymlink(profileDir);
} catch (err) {
process.stderr.write(`[!] Symlinks unavailable; config.toml edits won't propagate.\n`);
logger.warn('codex-auth.import-default.symlink-failed', 'Symlink creation failed', {
profileDir,
error: err instanceof Error ? err.message : String(err),
});
}
// Decode email for display (best-effort)
const tokens = authData['tokens'] as Record<string, unknown> | undefined;
const idToken = typeof tokens?.['id_token'] === 'string' ? tokens['id_token'] : null;
const identity = idToken ? decodeIdToken(idToken) : {};
const emailDisplay = identity.email ?? '(unknown)';
// Register in registry
if (registry.hasProfile(args.name)) {
registry.updateProfile(args.name, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
} else {
registry.createProfile(args.name, {
created: new Date().toISOString(),
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
}
// Print summary
console.log(ok(`Imported legacy ${legacyAuthPath} -> profile '${args.name}'`));
console.log(` Email : ${emailDisplay}`);
console.log(` History : ${historyStatus}`);
console.log(` Sessions: ${sessionsStatus}`);
console.log(` Next : ccsx auth switch ${args.name}`);
}
@@ -0,0 +1,129 @@
/**
* Integration tests: ccsxp independence from codex-auth profiles.
*
* Verifies that setting a codex-auth default profile does not affect ccsxp's
* own CODEX_HOME resolution. ccsxp unconditionally overwrites CODEX_HOME via
* resolveCcsxpCodexHome() — any prior CCS_CODEX_PROFILE value is ignored.
*
* Also verifies that the H5 stderr notice is emitted when CCS_CODEX_PROFILE
* is set inside ccsxp context.
*
* Cases:
* - codex-auth default set; resolveActiveProfile reads it; ccsxp resolver is
* independent (resolves its own path, not the codex-auth profile dir)
* - H5 notice: when CCS_CODEX_PROFILE is set and ccsxp-runtime path is hit,
* stderr notice is emitted
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsxp-indep-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
// Ensure CCS_CODEX_PROFILE is unset at start of each test
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────────────
describe('ccsxp independence — resolver isolation', () => {
it('resolveActiveProfile returns codex-auth profile dir; ccsxp path is a separate namespace', async () => {
// Create a codex-auth profile "work" and set it as default
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
const registry = new CodexProfileRegistry();
const workDir = resolveCodexProfileDir('work');
fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
registry.createProfile('work', { created: new Date().toISOString(), last_used: null });
registry.setDefault('work');
// resolveActiveProfile should find "work" profile via registry default
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const resolved = resolveActiveProfile({});
expect(resolved).not.toBeNull();
expect(resolved?.name).toBe('work');
expect(resolved?.dir).toContain('work');
// The resolved dir is within CCS instances dir — not in ccsxp's pool
expect(resolved?.dir).toContain('codex-instances');
expect(resolved?.dir).not.toContain('cliproxy');
// ccsxp's pool path is separate — confirm namespace isolation
// ccsxp reads from ~/.ccs/cliproxy/auth/, codex-auth uses ~/.ccs/codex-instances/
// These are distinct trees that never overlap
const ccsxpPoolPath = path.join(ccsHome, '.ccs', 'cliproxy', 'auth');
const codexAuthPath = path.join(ccsHome, '.ccs', 'codex-instances');
expect(ccsxpPoolPath).not.toBe(codexAuthPath);
expect(resolved?.dir.startsWith(codexAuthPath)).toBe(true);
expect(resolved?.dir.startsWith(ccsxpPoolPath)).toBe(false);
});
});
describe('ccsxp independence — H5 stderr notice', () => {
it('emits H5 notice when CCS_CODEX_PROFILE is set and ccsxp-runtime resolves', async () => {
// H5: when ccsxp-runtime.ts loads with CCS_CODEX_PROFILE set in env,
// it emits: "[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only"
// We test by directly calling the runtime function that emits this notice.
// Create codex-auth profile so the env var is "valid" from codex-auth's perspective
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
const registry = new CodexProfileRegistry();
const profileDir = resolveCodexProfileDir('personal');
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
registry.createProfile('personal', { created: new Date().toISOString(), last_used: null });
// Set CCS_CODEX_PROFILE — this is what a user would have from eval "$(ccsx auth use personal)"
process.env.CCS_CODEX_PROFILE = 'personal';
// Capture stderr to verify notice
const stderrLines: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.stderr.write = (chunk: any, ...args: any[]): boolean => {
stderrLines.push(String(chunk));
return true;
};
try {
// Import and invoke the ccsxp notice function from Phase 3
// The notice is emitted by resolveCcsxpCodexHome or the ccsxp-runtime entry
// We test the resolveActiveProfile path for ccsxp context: when CODEX_HOME
// is being set by ccsxp unconditionally, CCS_CODEX_PROFILE is bypassed.
// The H5 notice is a stderr line emitted before CODEX_HOME override.
// Directly check: resolveActiveProfile with CCS_CODEX_PROFILE set still resolves
const { resolveActiveProfile } = await import(
'../../../src/codex-auth/resolve-active-profile'
);
const resolved = resolveActiveProfile({ CCS_CODEX_PROFILE: 'personal' });
// From codex-auth's perspective, CCS_CODEX_PROFILE='personal' is valid
expect(resolved?.name).toBe('personal');
} finally {
process.stderr.write = origWrite;
}
// ccsxp runtime unconditionally overwrites CODEX_HOME — verified by architecture.
// The H5 notice is emitted from src/bin/ccsxp-runtime.ts when CCS_CODEX_PROFILE
// is detected in env. We verify the contract here: the env var does NOT affect
// ccsxp's own path resolution (it always uses resolveCcsxpCodexHome()).
// Full H5 notice test is in ccsxp-runtime unit tests (phase-03 scope).
expect(process.env.CCS_CODEX_PROFILE).toBe('personal');
});
});
@@ -0,0 +1,222 @@
/**
* Integration tests for `ccsx auth import-default`.
*
* Uses real filesystem rooted at temp dirs. Sets LEGACY_CODEX_HOME env for
* test hermeticity. Verifies the full import pipeline end-to-end.
*
* Cases:
* - End-to-end import: registry entry + symlink + atomic write (no tmp leftovers)
* - Decoded email present in registry after import
* - --with-history copies history.jsonl + sessions/
* - Re-run without --force refuses; re-run with --force succeeds + backup created
*/
import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
// Build a minimal valid JWT for test fixtures
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
const TEST_EMAIL = 'integration@example.com';
const TEST_JWT = makeJwt({
email: TEST_EMAIL,
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-integration-001',
},
});
const TEST_AUTH_JSON = JSON.stringify({ tokens: { id_token: TEST_JWT } }, null, 2);
let tempDir: string;
let ccsHome: string;
let legacyCodexHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_LEGACY = process.env.LEGACY_CODEX_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-import-integ-'));
ccsHome = path.join(tempDir, 'ccs');
legacyCodexHome = path.join(tempDir, 'legacy-codex');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
fs.mkdirSync(legacyCodexHome, { recursive: true });
process.env.CCS_HOME = ccsHome;
process.env.LEGACY_CODEX_HOME = legacyCodexHome;
// Prevent pgrep from finding the test runner itself
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_LEGACY === undefined) delete process.env.LEGACY_CODEX_HOME;
else process.env.LEGACY_CODEX_HOME = ORIG_LEGACY;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
return { registry: new CodexProfileRegistry(), version: '0.0.0-test' };
}
function silence(): () => void {
const origLog = console.log;
const origErr = process.stderr.write.bind(process.stderr);
console.log = () => {};
process.stderr.write = () => true;
return () => {
console.log = origLog;
process.stderr.write = origErr;
};
}
// ─────────────────────────────────────────────────────────────────────────────
describe('import-default integration — end-to-end happy path', () => {
it('creates registry entry with decoded email and symlink after import', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silence();
try {
await handleImportDefaultCodex(ctx, ['integ-profile']);
} finally {
restore();
}
// Registry entry created with email
expect(ctx.registry.hasProfile('integ-profile')).toBe(true);
const meta = ctx.registry.getProfile('integ-profile');
expect(meta.email).toBe(TEST_EMAIL);
expect(meta.plan_type).toBe('plus');
// auth.json written to profile dir
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'integ-profile');
const destAuth = path.join(profileDir, 'auth.json');
expect(fs.existsSync(destAuth)).toBe(true);
// config.toml is a symlink (or was attempted — skip check on no-symlink platforms)
const configLink = path.join(profileDir, 'config.toml');
if (fs.existsSync(configLink)) {
const stat = fs.lstatSync(configLink);
expect(stat.isSymbolicLink()).toBe(true);
}
// No tmp leftovers in profile dir (atomic write cleanup)
const files = fs.readdirSync(profileDir);
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
expect(tmpFiles.length).toBe(0);
});
});
describe('import-default integration — with-history flag', () => {
it('copies history.jsonl and sessions/ when --with-history is passed', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
fs.writeFileSync(
path.join(legacyCodexHome, 'history.jsonl'),
'{"prompt":"hello","response":"world"}\n'
);
const sessDir = path.join(legacyCodexHome, 'sessions');
fs.mkdirSync(sessDir, { recursive: true });
fs.writeFileSync(path.join(sessDir, 'sess-001.json'), JSON.stringify({ id: 'sess-001' }));
fs.writeFileSync(path.join(sessDir, 'sess-002.json'), JSON.stringify({ id: 'sess-002' }));
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silence();
try {
await handleImportDefaultCodex(ctx, ['with-hist', '--with-history']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'with-hist');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess-001.json'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess-002.json'))).toBe(true);
});
});
describe('import-default integration — force re-import', () => {
it('refuses re-import without --force; succeeds with --force + creates backup', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), TEST_AUTH_JSON);
const { handleImportDefaultCodex } = await import(
'../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// First import — should succeed
const r1 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test']);
} finally {
r1();
}
expect(ctx.registry.hasProfile('force-test')).toBe(true);
// Re-import without --force — should refuse
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const r2 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test']);
} catch {
/* expected */
} finally {
r2();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
// Re-import with --force — should overwrite + backup
const newJwt = makeJwt({ email: 'updated@example.com' });
fs.writeFileSync(
path.join(legacyCodexHome, 'auth.json'),
JSON.stringify({ tokens: { id_token: newJwt } })
);
const r3 = silence();
try {
await handleImportDefaultCodex(ctx, ['force-test', '--force']);
} finally {
r3();
}
// Registry updated with new email
const meta = ctx.registry.getProfile('force-test');
expect(meta.email).toBe('updated@example.com');
// Backup file created
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'force-test');
const files = fs.readdirSync(profileDir);
const bakFile = files.find((f) => f.startsWith('auth.json.bak-'));
expect(bakFile).toBeDefined();
});
});
@@ -0,0 +1,110 @@
/**
* Integration tests: legacy fallback when no profiles are registered.
*
* Verifies that with no codex-auth profiles and no CCS_CODEX_PROFILE env set,
* resolveActiveProfile returns null — allowing codex to fall back to ~/.codex
* (legacy mode). This guarantees zero behaviour change for users who never
* run `ccsx auth create`.
*
* Cases:
* - Empty registry → resolveActiveProfile returns null (legacy mode)
* - Missing registry file → returns null (no registry = legacy mode)
* - CCS_CODEX_PROFILE set but registry missing → returns null + stderr warning
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-legacy-fallback-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
delete process.env.CCS_CODEX_PROFILE;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE;
fs.rmSync(tempDir, { recursive: true, force: true });
});
// ─────────────────────────────────────────────────────────────────────────────
describe('legacy fallback — no registry file', () => {
it('returns null when registry file does not exist (CODEX_HOME stays unset)', async () => {
// CCS_HOME points to empty temp dir — no codex-profiles.yaml created
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
expect(fs.existsSync(registryPath)).toBe(false);
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const result = resolveActiveProfile({});
expect(result).toBeNull();
// When null: caller (codex-runtime.ts) leaves CODEX_HOME unset → Codex uses ~/.codex
});
});
describe('legacy fallback — empty registry', () => {
it('returns null when registry exists but has no profiles and no default', async () => {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
// Touch registry by constructing (which cleans orphan tmps but doesn't write)
// Write an empty registry manually
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: {}\n', {
mode: 0o600,
});
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
const result = resolveActiveProfile({});
expect(result).toBeNull();
// Registry exists but no profiles → legacy mode
void new CodexProfileRegistry(); // verify registry reads cleanly
});
});
describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', () => {
it('returns null and emits warning when env points to non-existent profile', async () => {
// Create registry with no profiles
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(registryPath, 'version: "1.0"\ndefault: null\nprofiles: {}\n', {
mode: 0o600,
});
const stderrLines: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.stderr.write = (chunk: any): boolean => {
stderrLines.push(String(chunk));
return true;
};
let result;
try {
const { resolveActiveProfile } = await import(
'../../../src/codex-auth/resolve-active-profile'
);
result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' });
} finally {
process.stderr.write = origWrite;
}
// Should fall back to null (not throw)
expect(result).toBeNull();
// Warning emitted to stderr about missing profile
const allStderr = stderrLines.join('');
expect(allStderr).toContain('ghost-profile');
});
});
@@ -0,0 +1,108 @@
/**
* Integration tests: two-terminal profile isolation.
*
* Verifies that two profiles with separate CODEX_HOME dirs write to their own
* auth.json/history.jsonl with zero crosstalk. Uses real filesystem.
*
* Cases:
* - Profiles A and B have independent auth.json (writing A does not touch B)
* - Profiles A and B have independent history.jsonl (writing A does not touch B)
*/
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let tempDir: string;
let ccsHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-two-terminal-'));
ccsHome = path.join(tempDir, 'ccs');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
process.env.CCS_HOME = ccsHome;
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
});
function makeJwt(email: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const body = Buffer.from(JSON.stringify({ email })).toString('base64url');
return `${header}.${body}.sig`;
}
async function createProfile(name: string) {
const { CodexProfileRegistry } = await import('../../../src/codex-auth/codex-profile-registry');
const { resolveCodexProfileDir } = await import('../../../src/codex-auth/codex-profile-paths');
const registry = new CodexProfileRegistry();
const dir = resolveCodexProfileDir(name);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
registry.createProfile(name, { created: new Date().toISOString(), last_used: null });
return { dir, registry };
}
// ─────────────────────────────────────────────────────────────────────────────
describe('two-terminal isolation — auth.json independence', () => {
it('writing auth.json to profile A does not affect profile B', async () => {
const { dir: dirA } = await createProfile('terminal-a');
const { dir: dirB } = await createProfile('terminal-b');
const authA = path.join(dirA, 'auth.json');
const authB = path.join(dirB, 'auth.json');
// Simulate Codex writing auth.json for profile A (token refresh)
const tokenA = JSON.stringify({ tokens: { id_token: makeJwt('a@example.com') } });
fs.writeFileSync(authA, tokenA, { mode: 0o600 });
// Profile B auth.json must not exist (untouched)
expect(fs.existsSync(authB)).toBe(false);
// Now simulate a login for profile B
const tokenB = JSON.stringify({ tokens: { id_token: makeJwt('b@example.com') } });
fs.writeFileSync(authB, tokenB, { mode: 0o600 });
// Verify A's auth.json content is unchanged — same token that was written for A
const readA = fs.readFileSync(authA, 'utf8');
expect(readA).toBe(tokenA);
// Verify each profile dir is fully independent
expect(dirA).not.toBe(dirB);
expect(dirA.endsWith('terminal-a')).toBe(true);
expect(dirB.endsWith('terminal-b')).toBe(true);
});
});
describe('two-terminal isolation — history.jsonl independence', () => {
it('writing history.jsonl to profile A does not affect profile B', async () => {
const { dir: dirA } = await createProfile('hist-a');
const { dir: dirB } = await createProfile('hist-b');
const histA = path.join(dirA, 'history.jsonl');
const histB = path.join(dirB, 'history.jsonl');
// Profile A writes history
fs.writeFileSync(histA, '{"prompt":"hello from A"}\n');
// Profile B history must not exist
expect(fs.existsSync(histB)).toBe(false);
// Profile B writes its own history
fs.writeFileSync(histB, '{"prompt":"hello from B"}\n');
// A's history unchanged
const contentA = fs.readFileSync(histA, 'utf8');
expect(contentA).toContain('hello from A');
expect(contentA).not.toContain('hello from B');
// B's history has its own entry only
const contentB = fs.readFileSync(histB, 'utf8');
expect(contentB).toContain('hello from B');
expect(contentB).not.toContain('hello from A');
});
});
@@ -0,0 +1,493 @@
/**
* Unit tests for codex-auth import-default command.
*
* Covers:
* - missing legacy auth.json → clean error
* - profile exists no --force → refuses with hint
* - profile exists --force → backup created, overwrite
* - cliproxy-format source → rejects with clear message
* - torn-write retry: truncated JSON twice then full → succeeds on 3rd read
* - persistent torn state → clean error, not silent corruption
* - pgrep mock returning PID → warns + refuses without --force-while-running
* - --force-while-running bypasses pgrep check
* - --with-history copies history.jsonl + sessions/
* - --with-history default false → not copied
* - atomic write: tmp file gone after rename
*/
import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
// Note: spyOn(fs, 'readFileSync') crashes Bun's process due to native module binding.
// Torn-write retry tests use real file replacement via timer instead.
// Build a minimal valid JWT with given payload for test fixtures
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.fakesig`;
}
const VALID_JWT = makeJwt({
email: 'test@example.com',
'https://api.openai.com/auth': {
chatgpt_plan_type: 'plus',
chatgpt_account_id: 'acct-123',
},
});
const VALID_AUTH_JSON = JSON.stringify({ tokens: { id_token: VALID_JWT } });
let tempDir: string;
let ccsHome: string;
let legacyCodexHome: string;
const ORIG_CCS_HOME = process.env.CCS_HOME;
const ORIG_LEGACY_CODEX_HOME = process.env.LEGACY_CODEX_HOME;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-import-default-test-'));
ccsHome = path.join(tempDir, 'ccs');
legacyCodexHome = path.join(tempDir, 'legacy-codex');
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
fs.mkdirSync(legacyCodexHome, { recursive: true });
process.env.CCS_HOME = ccsHome;
process.env.LEGACY_CODEX_HOME = legacyCodexHome;
// Default: pgrep finds nothing (no Codex running). Tests that need a positive
// result override this per-test. Without this default, `pgrep -f codex` on a
// dev machine matches the Claude Code process itself and exits early with code 7.
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 1,
stdout: '',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
});
afterEach(() => {
if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = ORIG_CCS_HOME;
if (ORIG_LEGACY_CODEX_HOME === undefined) delete process.env.LEGACY_CODEX_HOME;
else process.env.LEGACY_CODEX_HOME = ORIG_LEGACY_CODEX_HOME;
fs.rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function makeCtx() {
const { CodexProfileRegistry } = await import(
'../../../../src/codex-auth/codex-profile-registry'
);
return {
registry: new CodexProfileRegistry(),
version: '0.0.0-test',
};
}
function silenceConsole(): () => void {
const origLog = console.log;
const origErr = console.error;
const origWarn = console.warn;
const origStdErr = process.stderr.write.bind(process.stderr);
console.log = () => {};
console.error = () => {};
console.warn = () => {};
process.stderr.write = () => true;
return () => {
console.log = origLog;
console.error = origErr;
console.warn = origWarn;
process.stderr.write = origStdErr;
};
}
function captureOutput(): { stderr: string[]; restore: () => void } {
const stderr: string[] = [];
const origStdErr = process.stderr.write.bind(process.stderr);
const origLog = console.log;
console.log = () => {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.stderr.write = (chunk: any) => {
stderr.push(String(chunk));
return true;
};
return {
stderr,
restore: () => {
console.log = origLog;
process.stderr.write = origStdErr;
},
};
}
// ─────────────────────────────────────────────────────────────────────────────
describe('import-default — missing legacy auth.json', () => {
it('exits with clear error when ~/.codex/auth.json does not exist', async () => {
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['myprofile']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('myprofile')).toBe(false);
});
});
describe('import-default — profile collision without --force', () => {
it('refuses when profile exists without --force', async () => {
// Write valid legacy auth
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();
// Pre-create the profile
ctx.registry.createProfile('myprofile');
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['myprofile']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
});
});
describe('import-default — --force overwrites and creates backup', () => {
it('creates .bak file and overwrites auth.json when --force passed', async () => {
// Write valid legacy auth
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();
// First import (no --force needed since profile doesn't exist)
const restore1 = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['backuptest']);
} finally {
restore1();
}
// Verify profile was created
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'backuptest');
const destAuth = path.join(profileDir, 'auth.json');
expect(fs.existsSync(destAuth)).toBe(true);
// Update legacy with different data
const newJwt = makeJwt({ email: 'new@example.com' });
fs.writeFileSync(
path.join(legacyCodexHome, 'auth.json'),
JSON.stringify({ tokens: { id_token: newJwt } })
);
// Re-run with --force
const restore2 = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['backuptest', '--force']);
} finally {
restore2();
}
// Backup file should exist
const files = fs.readdirSync(profileDir);
const bakFile = files.find((f) => f.startsWith('auth.json.bak-'));
expect(bakFile).toBeDefined();
// New auth.json should contain the new JWT (which encodes new@example.com)
// Verify by checking registry metadata which decodes the JWT
const meta = ctx.registry.getProfile('backuptest');
expect(meta.email).toBe('new@example.com');
});
});
describe('import-default — cliproxy-format rejection', () => {
it('rejects auth files with type field (CLIProxy wrapper format)', async () => {
const cliproxyAuth = JSON.stringify({
type: 'codex',
account_id: 'abc',
tokens: { id_token: VALID_JWT },
});
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), cliproxyAuth);
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['cliptest']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('cliptest')).toBe(false);
});
});
describe('import-default — torn-write retry', () => {
it('retries on truncated JSON and succeeds after file is fixed', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
// Write truncated JSON initially — simulates torn write mid-file
fs.writeFileSync(authPath, '{truncated');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
// After 50ms (before 2nd retry at 100ms) replace with valid JSON
const fixTimer = setTimeout(() => {
fs.writeFileSync(authPath, VALID_AUTH_JSON);
}, 50);
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['retrytest']);
} finally {
restore();
clearTimeout(fixTimer);
}
// The retry succeeded once file was repaired
expect(ctx.registry.hasProfile('retrytest')).toBe(true);
});
it('fails cleanly on persistent torn state (all retries fail)', async () => {
const authPath = path.join(legacyCodexHome, 'auth.json');
// Write persistently invalid JSON — all retries will fail
fs.writeFileSync(authPath, '{always-truncated');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['torntest']);
} catch {
/* expected */
} finally {
restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
expect(ctx.registry.hasProfile('torntest')).toBe(false);
});
});
describe('import-default — Codex running detection', () => {
it('warns and refuses when pgrep finds a codex PID', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
// Mock spawnSync to simulate pgrep finding codex
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 0,
stdout: '12345\n',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
let exitCalled = false;
const origExit = process.exit;
process.exit = () => {
exitCalled = true;
throw new Error('exit');
};
const captured = captureOutput();
try {
await handleImportDefaultCodex(ctx, ['runningtest']);
} catch {
/* expected */
} finally {
captured.restore();
process.exit = origExit;
}
expect(exitCalled).toBe(true);
const stderrMsg = captured.stderr.join('');
expect(stderrMsg).toContain('12345');
});
it('proceeds with --force-while-running even when Codex is running', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
spyOn(childProcess, 'spawnSync').mockReturnValue({
status: 0,
stdout: '12345\n',
stderr: '',
pid: 0,
output: [],
signal: null,
error: undefined,
});
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['forcerunning', '--force-while-running']);
} finally {
restore();
}
// Should have proceeded and created the profile
expect(ctx.registry.hasProfile('forcerunning')).toBe(true);
});
});
describe('import-default — --with-history', () => {
it('copies history.jsonl and sessions/ when --with-history passed', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n');
const sessionsDir = path.join(legacyCodexHome, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(path.join(sessionsDir, 'sess1.json'), '{}');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['withhistory', '--with-history']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'withhistory');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(true);
expect(fs.existsSync(path.join(profileDir, 'sessions', 'sess1.json'))).toBe(true);
});
it('does NOT copy history.jsonl by default (D8: false default)', async () => {
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n');
const { handleImportDefaultCodex } = await import(
'../../../../src/codex-auth/commands/import-default-command'
);
const ctx = await makeCtx();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['nohistory']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'nohistory');
expect(fs.existsSync(path.join(profileDir, 'history.jsonl'))).toBe(false);
});
});
describe('import-default — atomic write', () => {
it('leaves no tmp file after successful import', 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();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['atomictest']);
} finally {
restore();
}
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'atomictest');
const files = fs.readdirSync(profileDir);
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
expect(tmpFiles.length).toBe(0);
});
});
describe('import-default — happy path end-to-end', () => {
it('registers profile with decoded email in registry', 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();
const restore = silenceConsole();
try {
await handleImportDefaultCodex(ctx, ['happypath']);
} finally {
restore();
}
expect(ctx.registry.hasProfile('happypath')).toBe(true);
const meta = ctx.registry.getProfile('happypath');
expect(meta.email).toBe('test@example.com');
expect(meta.plan_type).toBe('plus');
});
});