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 <cmd>` 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
This commit is contained in:
Tam Nhu Tran
2026-05-17 14:45:13 -04:00
parent bf92645b35
commit 8c604a040f
6 changed files with 560 additions and 2 deletions
+10
View File
@@ -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,
+78
View File
@@ -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<number> {
const subcommand = argv[2];
// ── auth branch ─────────────────────────────────────────────────────────
if (subcommand === 'auth') {
const { runCodexAuth } = require('../codex-auth/codex-auth-router') as {
runCodexAuth: (args: string[]) => Promise<number>;
};
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()
}
+5 -2
View File
@@ -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);
});
+79
View File
@@ -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<string, unknown>;
}
/** @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;
}