mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 02:14:25 +00:00
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:
1 parent
e99c4612a8
commit
631c799322
7 files changed
+1582
No files matched your search
@@ -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}`);
|
||||
}
|
||||
Reference in new issue
Block a user