feat(codex-auth): add profile registry + storage foundation

Adds the storage substrate for ccsx auth profile isolation.
Each Codex profile gets its own CODEX_HOME dir under
~/.ccs/codex-instances/<name>/ with isolated auth.json and
history.jsonl; config.toml is shared via symlink to ~/.codex/config.toml
so two terminals can run two Codex accounts simultaneously without
duplicating user config.

- CodexProfileRegistry (YAML, atomic write tmp.<pid>.<rand> + rename,
  orphan cleanup, full CRUD + default pointer)
- decode-id-token: pure base64 JWT decoder for OpenAI id_token,
  reads nested https://api.openai.com/auth claims (chatgpt_plan_type,
  chatgpt_account_id) and dual-path email
- ensureSharedConfigSymlink: self-healing, idempotent, overwrites
  stale entries with stderr warning
- 45 unit tests, all green

Foundation only — no CLI, no runtime injection, no dashboard.
Subsequent commits wire those in.
This commit is contained in:
Tam Nhu Tran committed 2026-05-17 14:44:07 -04:00
1 parent d2f7d3f407
commit 358b703d98
12 files changed
+1006

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import * as fs from 'fs';
import { createLogger } from '../services/logging';
import { decodeIdToken } from './decode-id-token';
import type { CodexAccountIdentity } from './types';
const logger = createLogger('codex-auth:identity');
interface AuthJson {
tokens?: {
id_token?: string;
};
}
/**
* Read auth.json from disk and extract display-safe identity fields.
* Returns {} on any error (missing file, bad JSON, missing token, decode failure).
* Never throws.
*/
export function decodeAccountIdentity(authJsonPath: string): CodexAccountIdentity {
try {
const raw = fs.readFileSync(authJsonPath, 'utf8');
const parsed = JSON.parse(raw) as AuthJson;
const idToken = parsed?.tokens?.id_token;
if (typeof idToken !== 'string' || idToken.length === 0) {
return {};
}
return decodeIdToken(idToken);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.identity.decode-failed',
`Failed to decode account identity from ${authJsonPath}: ${msg}`
);
return {};
}
}
+73
View File
@@ -0,0 +1,73 @@
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../services/logging';
import { getSharedCodexConfigPath } from './codex-profile-paths';
const logger = createLogger('codex-auth:symlink');
/**
* Ensure <profileDir>/config.toml is a symlink pointing to the shared
* ~/.codex/config.toml. Self-healing: recreates stale or missing symlinks.
*
* @param profileDir - The per-profile directory (will be created if missing).
* @param sharedConfigPath - Override for the shared target path (used in tests
* to avoid touching real ~/.codex/config.toml). Defaults to getSharedCodexConfigPath().
*/
export function ensureSharedConfigSymlink(profileDir: string, sharedConfigPath?: string): void {
const targetPath = sharedConfigPath ?? getSharedCodexConfigPath();
const linkPath = path.join(profileDir, 'config.toml');
// Ensure profile directory exists
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
// Ensure shared config target parent directory exists
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
// Create empty shared config if it doesn't exist — Codex will populate on first run
if (!fs.existsSync(targetPath)) {
fs.writeFileSync(targetPath, '', { mode: 0o600 });
logger.stage('dispatch', 'codex.shared-config.created', 'Created empty shared config.toml', {
path: targetPath,
});
}
// Inspect whatever currently exists at the link path
let existingStat: fs.Stats | null = null;
try {
existingStat = fs.lstatSync(linkPath);
} catch {
// ENOENT — nothing there yet, proceed to create
}
if (existingStat !== null) {
if (existingStat.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(linkPath);
if (currentTarget === targetPath) {
// Already correct — idempotent return
logger.stage('dispatch', 'codex.symlink.ok', 'Shared config symlink already correct', {
link: linkPath,
});
return;
}
// Stale symlink — remove and re-create
fs.unlinkSync(linkPath);
logger.stage('dispatch', 'codex.symlink.repaired', 'Replaced stale symlink', {
link: linkPath,
was: currentTarget,
now: targetPath,
});
} else {
// Regular file or other non-symlink entry — overwrite with warning
process.stderr.write(
`[!] codex-auth: overwriting regular file at ${linkPath} with symlink to shared config.toml\n`
);
fs.unlinkSync(linkPath);
}
}
fs.symlinkSync(targetPath, linkPath);
logger.stage('dispatch', 'codex.symlink.created', 'Created shared config symlink', {
link: linkPath,
target: targetPath,
});
}
+21
View File
@@ -0,0 +1,21 @@
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from '../utils/config-manager';
export function getCodexAuthRegistryPath(): string {
return path.join(getCcsDir(), 'codex-profiles.yaml');
}
export function getCodexInstancesDir(): string {
return path.join(getCcsDir(), 'codex-instances');
}
export function resolveCodexProfileDir(name: string): string {
return path.join(getCodexInstancesDir(), name);
}
// Uses os.homedir() intentionally — this is the upstream Codex location,
// not a CCS-owned path. Tests must override the shared config path explicitly.
export function getSharedCodexConfigPath(): string {
return path.join(os.homedir(), '.codex', 'config.toml');
}
+187
View File
@@ -0,0 +1,187 @@
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { createLogger } from '../services/logging';
import { getCodexAuthRegistryPath } from './codex-profile-paths';
import { CODEX_PROFILE_SCHEMA_VERSION } from './types';
import type { CodexProfileData, CodexProfileMetadata } from './types';
const logger = createLogger('codex-auth:registry');
function emptyRegistry(): CodexProfileData {
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
}
/**
* Registry for codex auth profiles stored at ~/.ccs/codex-profiles.yaml.
*
* All writes are atomic (tmp file + POSIX rename). Concurrent writers are
* safe: last-writer-wins for the default pointer; profile entries never
* partially corrupt because rename(2) is atomic.
*
* Constructor accepts an optional registryPath for test isolation.
*/
export class CodexProfileRegistry {
private readonly registryPath: string;
constructor(registryPath?: string) {
this.registryPath = registryPath ?? getCodexAuthRegistryPath();
this._cleanOrphanTmpFiles();
}
// ── private read/write ──────────────────────────────────────────────────
private _read(): CodexProfileData {
if (!fs.existsSync(this.registryPath)) {
return emptyRegistry();
}
try {
const raw = fs.readFileSync(this.registryPath, 'utf8');
const parsed = yaml.load(raw) as CodexProfileData | null;
if (!parsed || typeof parsed !== 'object' || !parsed.profiles) {
return emptyRegistry();
}
return parsed;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(
'codex-auth.registry.corrupt',
`Corrupt registry at ${this.registryPath}, returning empty state: ${msg}`
);
return emptyRegistry();
}
}
private _write(data: CodexProfileData): void {
const dir = path.dirname(this.registryPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Unique tmp suffix avoids collisions on concurrent writes and orphan leaks
const tmpPath = `${this.registryPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
try {
fs.writeFileSync(tmpPath, yaml.dump(data, { indent: 2, lineWidth: -1 }), {
mode: 0o600,
});
fs.renameSync(tmpPath, this.registryPath);
} catch (err) {
if (fs.existsSync(tmpPath)) {
try {
fs.unlinkSync(tmpPath);
} catch {
// best-effort cleanup
}
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to write codex profile registry: ${msg}`);
}
}
// Best-effort cleanup of orphan tmp files older than 1 hour (H3 mitigation).
private _cleanOrphanTmpFiles(): void {
const dir = path.dirname(this.registryPath);
const base = path.basename(this.registryPath);
if (!fs.existsSync(dir)) return;
try {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const entry of fs.readdirSync(dir)) {
if (!entry.startsWith(`${base}.tmp.`)) continue;
const full = path.join(dir, entry);
try {
const stat = fs.statSync(full);
if (stat.mtimeMs < oneHourAgo) {
fs.unlinkSync(full);
}
} catch {
// ignore per-file errors
}
}
} catch {
// ignore cleanup failure silently
}
}
// ── CRUD ────────────────────────────────────────────────────────────────
createProfile(name: string, meta: Partial<CodexProfileMetadata> = {}): void {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
data.profiles[name] = {
type: 'codex',
created: new Date().toISOString(),
last_used: null,
...meta,
} as CodexProfileMetadata;
this._write(data);
logger.stage('route', 'codex-auth.profile.created', 'Codex profile created', { name });
}
getProfile(name: string): CodexProfileMetadata {
const data = this._read();
const profile = data.profiles[name];
if (!profile) {
throw new Error(`Profile not found: ${name}`);
}
return profile;
}
updateProfile(name: string, partial: Partial<CodexProfileMetadata>): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = { ...data.profiles[name], ...partial } as CodexProfileMetadata;
this._write(data);
}
removeProfile(name: string): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
delete data.profiles[name];
if (data.default === name) {
const remaining = Object.keys(data.profiles);
data.default = remaining.length > 0 ? remaining[0] : null;
}
this._write(data);
logger.stage('cleanup', 'codex-auth.profile.deleted', 'Codex profile removed', { name });
}
listProfiles(): string[] {
return Object.keys(this._read().profiles);
}
hasProfile(name: string): boolean {
return Object.prototype.hasOwnProperty.call(this._read().profiles, name);
}
// ── Default pointer ──────────────────────────────────────────────────────
getDefault(): string | null {
return this._read().default;
}
setDefault(name: string): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
}
clearDefault(): void {
const data = this._read();
data.default = null;
this._write(data);
}
touchProfile(name: string): void {
this.updateProfile(name, { last_used: new Date().toISOString() });
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { CodexAccountIdentity } from './types';
// JWT claim URI for OpenAI-specific auth data (nested object).
// Verified against real auth.json: chatgpt_plan_type and chatgpt_account_id
// live under this key, NOT at top level.
const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth';
const OPENAI_PROFILE_CLAIM = 'https://api.openai.com/profile';
interface OpenAIAuthClaim {
chatgpt_plan_type?: string;
chatgpt_account_id?: string;
}
interface OpenAIProfileClaim {
email?: string;
}
interface JwtPayload {
email?: string;
[OPENAI_AUTH_CLAIM]?: OpenAIAuthClaim;
[OPENAI_PROFILE_CLAIM]?: OpenAIProfileClaim;
[key: string]: unknown;
}
function base64urlDecode(str: string): string {
// Convert base64url to standard base64
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
return Buffer.from(padded, 'base64').toString('utf8');
}
/**
* Decode the payload of a JWT id_token without signature verification.
* Returns only the display-safe fields: email, plan_type, account_id.
* Returns {} on any parse failure — never throws.
*
* Security note: signature is NOT verified. This is purely cosmetic data
* for dashboard display. Auth boundary is OS file perms on auth.json.
*/
export function decodeIdToken(idToken: string): CodexAccountIdentity {
try {
const parts = idToken.split('.');
if (parts.length < 3) {
return {};
}
const rawPayload = base64urlDecode(parts[1]);
const payload = JSON.parse(rawPayload) as JwtPayload;
const authClaim = payload[OPENAI_AUTH_CLAIM];
const profileClaim = payload[OPENAI_PROFILE_CLAIM];
// Email: prefer top-level, fall back to profile claim
const email = payload.email ?? profileClaim?.email;
const result: CodexAccountIdentity = {};
if (typeof email === 'string' && email.length > 0) {
result.email = email;
}
if (typeof authClaim?.chatgpt_plan_type === 'string') {
result.plan_type = authClaim.chatgpt_plan_type;
}
if (typeof authClaim?.chatgpt_account_id === 'string') {
result.account_id = authClaim.chatgpt_account_id;
}
return result;
} catch {
return {};
}
}
+12
View File
@@ -0,0 +1,12 @@
export { CodexProfileRegistry } from './codex-profile-registry';
export {
getCodexAuthRegistryPath,
getCodexInstancesDir,
resolveCodexProfileDir,
getSharedCodexConfigPath,
} from './codex-profile-paths';
export { ensureSharedConfigSymlink } from './codex-config-symlink';
export { decodeAccountIdentity } from './codex-account-identity';
export { decodeIdToken } from './decode-id-token';
export type { CodexProfileMetadata, CodexProfileData, CodexAccountIdentity } from './types';
export { CODEX_PROFILE_SCHEMA_VERSION } from './types';
+22
View File
@@ -0,0 +1,22 @@
export interface CodexProfileMetadata {
type: 'codex';
created: string;
last_used: string | null;
email?: string;
plan_type?: string | null;
account_id?: string;
}
export interface CodexProfileData {
version: string;
default: string | null;
profiles: Record<string, CodexProfileMetadata>;
}
export interface CodexAccountIdentity {
email?: string;
plan_type?: string;
account_id?: string;
}
export const CODEX_PROFILE_SCHEMA_VERSION = '1.0';