From 8c604a040ff4aab2f00a87c28ec20c62e8191875 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 17 May 2026 14:45:13 -0400 Subject: [PATCH] feat(codex-auth): wire ccsx bin router + ccsxp scope notice Upgrades the previously-stub ccsx binary entry (src/bin/codex-runtime.ts) into an argv router: `ccsx auth ` dispatches to the Phase 2 router; any other argv resolves the active codex-auth profile and spawns codex with CODEX_HOME pointed at the profile dir. - resolve-active-profile: sync, hot-path-safe (<5ms), reads YAML registry via Phase 1 helpers; precedence is CODEX_HOME (explicit) > CCS_CODEX_PROFILE (env) > registry default > null (legacy ~/.codex fallback); fails open on any error (silent for missing registry, stderr warn for corrupt/missing-profile) - codex-runtime-router: extracted main() for testability; entry script is a thin 3-line wrapper; returns -1 sentinel for the CCS branch so the spawn lifecycle isn't terminated - ccsxp-runtime: H5 defensive stderr notice when CCS_CODEX_PROFILE is set, surfacing the boundary between codex-auth (native codex) and ccsxp (cliproxy pool) without changing functional behavior; CLIProxyAPI does not read CODEX_HOME so no pool contamination possible - 14 unit tests (8 resolver + 6 router); ccsxp regression suite (5 tests) untouched and still green --- src/bin/ccsxp-runtime.ts | 10 + src/bin/codex-runtime-router.ts | 78 +++++++ src/bin/codex-runtime.ts | 7 +- src/codex-auth/resolve-active-profile.ts | 79 +++++++ tests/unit/bin/codex-runtime-router.test.ts | 187 ++++++++++++++++ .../codex-auth/resolve-active-profile.test.ts | 201 ++++++++++++++++++ 6 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 src/bin/codex-runtime-router.ts create mode 100644 src/codex-auth/resolve-active-profile.ts create mode 100644 tests/unit/bin/codex-runtime-router.test.ts create mode 100644 tests/unit/codex-auth/resolve-active-profile.test.ts diff --git a/src/bin/ccsxp-runtime.ts b/src/bin/ccsxp-runtime.ts index 273b8205..41944a0e 100644 --- a/src/bin/ccsxp-runtime.ts +++ b/src/bin/ccsxp-runtime.ts @@ -68,6 +68,16 @@ function resolveCcsxpCodexHome() { return path.join(os.homedir(), '.codex'); } +// H5: CCS_CODEX_PROFILE is ignored by ccsxp. The ccsx auth profile system +// (src/codex-auth/) is intentionally NOT consulted here — ccsxp serves the +// cliproxy round-robin pool, not per-user-account profiles. Emit a one-line +// notice so users who set CCS_CODEX_PROFILE in their shell don't get confused +// when ccsxp silently ignores it and overwrites CODEX_HOME below. +if (process.env.CCS_CODEX_PROFILE) { + process.stderr.write( + "[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only.\n" + ); +} process.env.CODEX_HOME = resolveCcsxpCodexHome(); // ccsxp is the Codex + cliproxy shortcut. Keep the native Codex history root, diff --git a/src/bin/codex-runtime-router.ts b/src/bin/codex-runtime-router.ts new file mode 100644 index 00000000..009eab38 --- /dev/null +++ b/src/bin/codex-runtime-router.ts @@ -0,0 +1,78 @@ +/** + * Codex runtime router — testable logic for src/bin/codex-runtime.ts. + * + * All inter-module deps are resolved via require() at call-time so tests can + * inject stubs via require.cache before calling main(). + * + * Routing: + * argv[2] === 'auth' → delegate to runCodexAuth(argv.slice(3)), exit with code + * else → resolve active profile, set CODEX_HOME, load ccs + * CCS manages the process lifecycle; entry MUST NOT + * call process.exit() when main returns -1. + * + * Return value contract: + * ≥ 0 → auth branch: caller should process.exit(code) + * -1 → CCS branch: CCS has taken over the process; caller must NOT exit + */ + +process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; + +/** + * Main entry-point for the ccsx / codex-runtime binary. + * + * @param argv - process.argv (or test-supplied equivalent) + * @returns ≥0 exit code for auth branch; -1 for CCS branch (no exit needed) + */ +export async function main(argv: string[]): Promise { + const subcommand = argv[2]; + + // ── auth branch ───────────────────────────────────────────────────────── + if (subcommand === 'auth') { + const { runCodexAuth } = require('../codex-auth/codex-auth-router') as { + runCodexAuth: (args: string[]) => Promise; + }; + return runCodexAuth(argv.slice(3)); + } + + // ── non-auth branch: profile resolution ───────────────────────────────── + + // F1: respect an explicit CODEX_HOME — ccsxp, user export, CI override, etc. + const explicit = (process.env.CODEX_HOME ?? '').trim(); + if (!explicit) { + try { + const { resolveActiveProfile } = require('../codex-auth/resolve-active-profile') as { + resolveActiveProfile: ( + env: NodeJS.ProcessEnv + ) => { name: string; dir: string; source: string } | null; + }; + const resolved = resolveActiveProfile(process.env); + if (resolved) { + process.env.CODEX_HOME = resolved.dir; + + try { + const { ensureSharedConfigSymlink } = require('../codex-auth/codex-config-symlink') as { + ensureSharedConfigSymlink: (dir: string) => void; + }; + ensureSharedConfigSymlink(resolved.dir); + } catch (symlinkErr) { + const msg = symlinkErr instanceof Error ? symlinkErr.message : String(symlinkErr); + process.stderr.write( + `[!] codex-auth: shared config symlink failed (${msg}), continuing\n` + ); + } + } + } catch (resolverErr) { + // Resolver module threw unexpectedly — degrade silently to legacy mode + const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr); + process.stderr.write(`[!] codex-auth: profile resolution skipped (${msg})\n`); + } + } + + // ── delegate to CCS ───────────────────────────────────────────────────── + // require() is evaluated AFTER env mutations above. CCS manages its own + // process lifecycle (spawns codex, pipes stdio, calls process.exit). + // Return -1 so the entry script knows NOT to call process.exit(). + require('../ccs'); + + return -1; // CCS is in control — entry must not call process.exit() +} diff --git a/src/bin/codex-runtime.ts b/src/bin/codex-runtime.ts index 00478915..f102f9d9 100644 --- a/src/bin/codex-runtime.ts +++ b/src/bin/codex-runtime.ts @@ -1,2 +1,5 @@ -process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex'; -require('../ccs'); +import { main } from './codex-runtime-router'; +// -1 means CCS has taken over the process lifecycle; do not exit. +main(process.argv).then((code) => { + if (code >= 0) process.exit(code); +}); diff --git a/src/codex-auth/resolve-active-profile.ts b/src/codex-auth/resolve-active-profile.ts new file mode 100644 index 00000000..6bb85700 --- /dev/null +++ b/src/codex-auth/resolve-active-profile.ts @@ -0,0 +1,79 @@ +/** + * Synchronous hot-path resolver for the active codex auth profile. <5ms typical. + * Precedence: CCS_CODEX_PROFILE env → registry.default → null (legacy ~/.codex). + * Errors degrade gracefully — never throw. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths'; + +export interface ResolvedProfile { + name: string; + dir: string; + source: 'env' | 'default'; +} + +interface RegistryShape { + version?: string; + default?: string | null; + profiles?: Record; +} + +/** @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(); + + // F4: silent fallback — no registry means no profiles, legacy mode + if (!fs.existsSync(registryPath)) return null; + + let registry: RegistryShape; + try { + const raw = fs.readFileSync(registryPath, 'utf8'); + const parsed = yaml.load(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + process.stderr.write( + `[!] codex-auth: registry at ${registryPath} is not a valid YAML object, falling back to ~/.codex\n` + ); + return null; + } + registry = parsed as RegistryShape; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n` + ); + return null; + } + + const profiles = registry.profiles ?? {}; + + // F2: explicit env override + const envName = (env.CCS_CODEX_PROFILE ?? '').trim(); + if (envName) { + if (!Object.prototype.hasOwnProperty.call(profiles, envName)) { + process.stderr.write( + `[!] codex-auth: CCS_CODEX_PROFILE='${envName}' not found in registry, falling back to ~/.codex\n` + ); + return null; + } + return { + name: envName, + dir: path.resolve(resolveCodexProfileDir(envName)), + source: 'env', + }; + } + + // F3: registry default + const defaultName = registry.default ?? null; + if (defaultName && Object.prototype.hasOwnProperty.call(profiles, defaultName)) { + return { + name: defaultName, + dir: path.resolve(resolveCodexProfileDir(defaultName)), + source: 'default', + }; + } + + // F4: no profile configured + return null; +} diff --git a/tests/unit/bin/codex-runtime-router.test.ts b/tests/unit/bin/codex-runtime-router.test.ts new file mode 100644 index 00000000..1798b978 --- /dev/null +++ b/tests/unit/bin/codex-runtime-router.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for the codex-runtime router module (src/bin/codex-runtime-router.ts). + * + * Strategy: mirrors ccsxp-runtime.test.ts — invalidate require.cache before + * each test so the module re-evaluates with updated stubs and env state. + * Stub runCodexAuth and require('../ccs') via require.cache injection. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +const routerPath = require.resolve('../../../src/bin/codex-runtime-router.ts'); +const ccsPath = require.resolve('../../../src/ccs.ts'); +const codexAuthRouterPath = require.resolve('../../../src/codex-auth/codex-auth-router.ts'); +const resolveProfilePath = require.resolve('../../../src/codex-auth/resolve-active-profile.ts'); +const symlinkPath = require.resolve('../../../src/codex-auth/codex-config-symlink.ts'); + +const ORIGINAL_CCS_HOME = process.env.CCS_HOME; +const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME; +const ORIGINAL_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE; + +let tempDir: string; +let ccsHome: string; +let registryPath: string; +let instancesDir: string; + +function flushRouterCache() { + delete require.cache[routerPath]; + delete require.cache[codexAuthRouterPath]; + delete require.cache[resolveProfilePath]; + delete require.cache[symlinkPath]; + // Keep ccsPath stub intact — tests inject it explicitly each time +} + +function writeRegistry(data: object): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, yaml.dump(data, { indent: 2 }), { mode: 0o600 }); +} + +function makeProfileDir(name: string): string { + const dir = path.join(instancesDir, name); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 }); + process.env.CCS_HOME = ccsHome; + registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); + instancesDir = path.join(ccsHome, '.ccs', 'codex-instances'); + delete process.env.CODEX_HOME; + delete process.env.CCS_CODEX_PROFILE; + flushRouterCache(); +}); + +afterEach(() => { + if (ORIGINAL_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIGINAL_CCS_HOME; + + if (ORIGINAL_CODEX_HOME === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = ORIGINAL_CODEX_HOME; + + if (ORIGINAL_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE; + else process.env.CCS_CODEX_PROFILE = ORIGINAL_CCS_CODEX_PROFILE; + + flushRouterCache(); + delete require.cache[ccsPath]; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +// ── Auth routing ────────────────────────────────────────────────────────────── + +describe('codex-runtime router — auth subcommand routing', () => { + it('routes argv[2]===auth to runCodexAuth with remaining args and returns its exit code', async () => { + let capturedArgs: string[] | undefined; + + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + require.cache[codexAuthRouterPath] = { + exports: { + runCodexAuth: async (args: string[]) => { + capturedArgs = args; + return 0; + }, + }, + } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + const code = await main(['node', 'codex-runtime', 'auth', 'create', 'work']); + + expect(capturedArgs).toEqual(['create', 'work']); + expect(code).toBe(0); + }); + + it('returns non-zero exit code propagated from runCodexAuth', async () => { + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + require.cache[codexAuthRouterPath] = { + exports: { runCodexAuth: async (_args: string[]) => 1 }, + } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + const code = await main(['node', 'codex-runtime', 'auth', 'login']); + + expect(code).toBe(1); + }); +}); + +// ── Non-auth profile resolution ─────────────────────────────────────────────── + +describe('codex-runtime router — non-auth profile resolution', () => { + it('sets CODEX_HOME from active profile when CCS_CODEX_PROFILE env matches registry', async () => { + const profileDir = makeProfileDir('work'); + writeRegistry({ + version: '1.0', + default: null, + profiles: { work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } }, + }); + process.env.CCS_CODEX_PROFILE = 'work'; + + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + // Let real resolve-active-profile + codex-config-symlink run; stub ccs only + flushRouterCache(); + delete require.cache[ccsPath]; // ensure fresh load guard doesn't skip + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + await main(['node', 'codex-runtime', 'chat']); + + expect(process.env.CODEX_HOME).toBe(profileDir); + }); + + it('leaves CODEX_HOME unset when no registry exists and no env profile set', async () => { + // No registry file, no CCS_CODEX_PROFILE + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + const code = await main(['node', 'codex-runtime', 'chat']); + + expect(process.env.CODEX_HOME).toBeUndefined(); + expect(code).toBe(-1); // CCS branch: entry must not call process.exit() + }); + + it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => { + const explicitHome = path.join(tempDir, 'explicit-codex-home'); + fs.mkdirSync(explicitHome, { recursive: true }); + process.env.CODEX_HOME = explicitHome; + + // Even with a populated registry default, explicit wins + makeProfileDir('other'); + writeRegistry({ + version: '1.0', + default: 'other', + profiles: { other: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } }, + }); + + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + await main(['node', 'codex-runtime', 'chat']); + + expect(process.env.CODEX_HOME).toBe(explicitHome); + }); + + it('sets CODEX_HOME from registry default when no CCS_CODEX_PROFILE is set', async () => { + const profileDir = makeProfileDir('personal'); + writeRegistry({ + version: '1.0', + default: 'personal', + profiles: { + personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null }, + }, + }); + // No CCS_CODEX_PROFILE set + + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + flushRouterCache(); + require.cache[ccsPath] = { exports: {} } as NodeJS.Module; + + const { main } = require(routerPath) as { main: (argv: string[]) => Promise }; + await main(['node', 'codex-runtime', '--version']); + + expect(process.env.CODEX_HOME).toBe(profileDir); + }); +}); diff --git a/tests/unit/codex-auth/resolve-active-profile.test.ts b/tests/unit/codex-auth/resolve-active-profile.test.ts new file mode 100644 index 00000000..0cb2f95c --- /dev/null +++ b/tests/unit/codex-auth/resolve-active-profile.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +let resolveActiveProfile: ( + env?: NodeJS.ProcessEnv +) => { name: string; dir: string; source: 'env' | 'default' } | null; + +const ORIGINAL_CCS_HOME = process.env.CCS_HOME; + +let tempDir: string; +let ccsHome: string; +let registryPath: string; +let instancesDir: string; + +beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-profile-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 }); + process.env.CCS_HOME = ccsHome; + + registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml'); + instancesDir = path.join(ccsHome, '.ccs', 'codex-instances'); + + // Re-import after setting CCS_HOME so module picks up updated dir + const mod = await import('../../../src/codex-auth/resolve-active-profile'); + resolveActiveProfile = mod.resolveActiveProfile; +}); + +afterEach(() => { + if (ORIGINAL_CCS_HOME === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = ORIGINAL_CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +// Helper to write a registry YAML fixture +function writeRegistry(data: object): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, yaml.dump(data, { indent: 2 }), { mode: 0o600 }); +} + +function makeProfileDir(name: string): string { + const dir = path.join(instancesDir, name); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; +} + +describe('resolveActiveProfile', () => { + it('returns null silently when registry file does not exist', () => { + // No registry written + const result = resolveActiveProfile({}); + expect(result).toBeNull(); + }); + + it('returns null and warns to stderr when registry YAML is corrupt', () => { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 }); + + const stderrMessages: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + const spy = spyOn(process.stderr, 'write').mockImplementation( + (msg: string | Uint8Array, ...rest: unknown[]) => { + stderrMessages.push(typeof msg === 'string' ? msg : String(msg)); + return origWrite(msg as string, ...(rest as Parameters).slice(1)); + } + ); + + const result = resolveActiveProfile({}); + + spy.mockRestore(); + + expect(result).toBeNull(); + expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true); + }); + + it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => { + const profileDir = makeProfileDir('work'); + writeRegistry({ + version: '1.0', + default: null, + profiles: { + work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null }, + }, + }); + + const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' }); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('work'); + expect(result?.source).toBe('env'); + expect(result?.dir).toBe(profileDir); + }); + + it('returns source=default when no env var and registry has a default profile', () => { + const profileDir = makeProfileDir('personal'); + writeRegistry({ + version: '1.0', + default: 'personal', + profiles: { + personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null }, + }, + }); + + const result = resolveActiveProfile({}); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('personal'); + expect(result?.source).toBe('default'); + expect(result?.dir).toBe(profileDir); + }); + + it('env > default precedence: CCS_CODEX_PROFILE overrides registry default', () => { + makeProfileDir('work'); + makeProfileDir('personal'); + writeRegistry({ + version: '1.0', + default: 'personal', + profiles: { + work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null }, + personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null }, + }, + }); + + const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' }); + + expect(result?.name).toBe('work'); + expect(result?.source).toBe('env'); + }); + + it('returns null and warns when CCS_CODEX_PROFILE names a profile not in registry', () => { + writeRegistry({ + version: '1.0', + default: null, + profiles: {}, + }); + + const stderrMessages: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + const spy = spyOn(process.stderr, 'write').mockImplementation( + (msg: string | Uint8Array, ...rest: unknown[]) => { + stderrMessages.push(typeof msg === 'string' ? msg : String(msg)); + return origWrite(msg as string, ...(rest as Parameters).slice(1)); + } + ); + + const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' }); + + spy.mockRestore(); + + expect(result).toBeNull(); + expect(stderrMessages.some((m) => m.includes('ghost'))).toBe(true); + }); + + it('treats empty/whitespace-only CCS_CODEX_PROFILE as unset, falls back to default', () => { + makeProfileDir('default-profile'); + writeRegistry({ + version: '1.0', + default: 'default-profile', + profiles: { + 'default-profile': { + type: 'codex', + created: '2026-01-01T00:00:00.000Z', + last_used: null, + }, + }, + }); + + const resultEmpty = resolveActiveProfile({ CCS_CODEX_PROFILE: '' }); + expect(resultEmpty?.name).toBe('default-profile'); + expect(resultEmpty?.source).toBe('default'); + + const resultWhitespace = resolveActiveProfile({ CCS_CODEX_PROFILE: ' ' }); + expect(resultWhitespace?.name).toBe('default-profile'); + expect(resultWhitespace?.source).toBe('default'); + }); + + it('resolves the profile dir to an absolute path', () => { + makeProfileDir('absolute-test'); + writeRegistry({ + version: '1.0', + default: 'absolute-test', + profiles: { + 'absolute-test': { + type: 'codex', + created: '2026-01-01T00:00:00.000Z', + last_used: null, + }, + }, + }); + + const result = resolveActiveProfile({}); + + expect(result?.dir).toBe(path.resolve(result?.dir ?? '')); + expect(path.isAbsolute(result?.dir ?? '')).toBe(true); + }); +});