mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,75 @@
|
||||
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 decodeAccountIdentity: (authJsonPath: string) => {
|
||||
email?: string;
|
||||
plan_type?: string;
|
||||
account_id?: string;
|
||||
};
|
||||
|
||||
function buildToken(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_TOKEN = buildToken({
|
||||
email: 'test@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_account_id: 'acct-abc123',
|
||||
},
|
||||
});
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-identity-test-'));
|
||||
const mod = await import('../../../src/codex-auth/codex-account-identity');
|
||||
decodeAccountIdentity = mod.decodeAccountIdentity;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('decodeAccountIdentity', () => {
|
||||
it('returns {} when auth.json does not exist', () => {
|
||||
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns identity fields from valid auth.json with id_token', () => {
|
||||
const authJson = {
|
||||
auth_mode: 'chatgpt_oauth',
|
||||
tokens: { id_token: VALID_TOKEN, access_token: 'acc', refresh_token: 'ref' },
|
||||
};
|
||||
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
|
||||
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
||||
expect(result.email).toBe('test@example.com');
|
||||
expect(result.plan_type).toBe('pro');
|
||||
expect(result.account_id).toBe('acct-abc123');
|
||||
});
|
||||
|
||||
it('returns {} when auth.json contains corrupt JSON', () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'auth.json'), '{ not valid json !!!', { mode: 0o600 });
|
||||
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when id_token field is missing from tokens', () => {
|
||||
const authJson = { auth_mode: 'openai', tokens: { access_token: 'acc' } };
|
||||
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
|
||||
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when tokens field is absent entirely', () => {
|
||||
const authJson = { auth_mode: 'openai', OPENAI_API_KEY: 'sk-...' };
|
||||
fs.writeFileSync(path.join(tempDir, 'auth.json'), JSON.stringify(authJson), { mode: 0o600 });
|
||||
const result = decodeAccountIdentity(path.join(tempDir, 'auth.json'));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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 ensureSharedConfigSymlink: (profileDir: string, sharedConfigPath?: string) => void;
|
||||
|
||||
let tempDir: string;
|
||||
let profileDir: string;
|
||||
let sharedConfigPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-symlink-test-'));
|
||||
profileDir = path.join(tempDir, 'profile');
|
||||
// Use a temp path for the shared config so tests never touch real ~/.codex/config.toml
|
||||
sharedConfigPath = path.join(tempDir, 'shared-config.toml');
|
||||
const mod = await import('../../../src/codex-auth/codex-config-symlink');
|
||||
ensureSharedConfigSymlink = mod.ensureSharedConfigSymlink;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('ensureSharedConfigSymlink', () => {
|
||||
it('creates empty shared config and symlink when neither exists', () => {
|
||||
// Neither profileDir nor sharedConfigPath exist yet
|
||||
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
||||
|
||||
expect(fs.existsSync(sharedConfigPath)).toBe(true);
|
||||
expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('');
|
||||
|
||||
const linkPath = path.join(profileDir, 'config.toml');
|
||||
const stat = fs.lstatSync(linkPath);
|
||||
expect(stat.isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
||||
});
|
||||
|
||||
it('is idempotent when symlink already points to correct target', () => {
|
||||
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
||||
// Call again — should not throw
|
||||
expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).not.toThrow();
|
||||
|
||||
const linkPath = path.join(profileDir, 'config.toml');
|
||||
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
||||
});
|
||||
|
||||
it('preserves existing shared config content (does not overwrite)', () => {
|
||||
fs.writeFileSync(sharedConfigPath, '[model]\nname = "o4"', { mode: 0o600 });
|
||||
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
||||
expect(fs.readFileSync(sharedConfigPath, 'utf8')).toBe('[model]\nname = "o4"');
|
||||
});
|
||||
|
||||
it('replaces a stale symlink pointing to a wrong target with correct one', () => {
|
||||
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
||||
const wrongTarget = path.join(tempDir, 'wrong.toml');
|
||||
fs.writeFileSync(wrongTarget, '', { mode: 0o600 });
|
||||
const linkPath = path.join(profileDir, 'config.toml');
|
||||
fs.symlinkSync(wrongTarget, linkPath);
|
||||
|
||||
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
||||
|
||||
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
||||
});
|
||||
|
||||
it('replaces a regular file at link path with symlink (with warning)', () => {
|
||||
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
||||
const linkPath = path.join(profileDir, 'config.toml');
|
||||
fs.writeFileSync(linkPath, '[existing]\ndata = true', { mode: 0o600 });
|
||||
|
||||
// Capture stderr to verify warning was written
|
||||
const stderrChunks: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array): boolean => {
|
||||
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
|
||||
return origWrite(chunk);
|
||||
};
|
||||
|
||||
try {
|
||||
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
}
|
||||
|
||||
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
||||
// A warning should have been emitted
|
||||
expect(stderrChunks.join('')).toMatch(/overwr|replaced|regular file/i);
|
||||
});
|
||||
|
||||
it('replaces a broken symlink (dangling) with correct symlink', () => {
|
||||
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
||||
const linkPath = path.join(profileDir, 'config.toml');
|
||||
// Create symlink to non-existent target
|
||||
fs.symlinkSync(path.join(tempDir, 'does-not-exist.toml'), linkPath);
|
||||
// Verify it's broken
|
||||
expect(fs.existsSync(linkPath)).toBe(false);
|
||||
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
|
||||
ensureSharedConfigSymlink(profileDir, sharedConfigPath);
|
||||
|
||||
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(linkPath)).toBe(sharedConfigPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
let getCodexAuthRegistryPath: () => string;
|
||||
let getCodexInstancesDir: () => string;
|
||||
let resolveCodexProfileDir: (name: string) => string;
|
||||
let getSharedCodexConfigPath: () => string;
|
||||
|
||||
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.CCS_HOME = '/tmp/test-ccs-home';
|
||||
// Re-import to pick up env change — use dynamic import with cache busting
|
||||
const mod = await import('../../../src/codex-auth/codex-profile-paths');
|
||||
getCodexAuthRegistryPath = mod.getCodexAuthRegistryPath;
|
||||
getCodexInstancesDir = mod.getCodexInstancesDir;
|
||||
resolveCodexProfileDir = mod.resolveCodexProfileDir;
|
||||
getSharedCodexConfigPath = mod.getSharedCodexConfigPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_CCS_HOME === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = ORIGINAL_CCS_HOME;
|
||||
}
|
||||
});
|
||||
|
||||
describe('codex-profile-paths', () => {
|
||||
it('getCodexAuthRegistryPath returns codex-profiles.yaml inside getCcsDir()', () => {
|
||||
const result = getCodexAuthRegistryPath();
|
||||
expect(result).toContain('codex-profiles.yaml');
|
||||
expect(result).toContain('.ccs');
|
||||
});
|
||||
|
||||
it('getCodexInstancesDir returns codex-instances inside getCcsDir()', () => {
|
||||
const result = getCodexInstancesDir();
|
||||
expect(result).toContain('codex-instances');
|
||||
expect(result).toContain('.ccs');
|
||||
});
|
||||
|
||||
it('resolveCodexProfileDir returns instancesDir/<name>', () => {
|
||||
const instancesDir = getCodexInstancesDir();
|
||||
const profileDir = resolveCodexProfileDir('work');
|
||||
expect(profileDir).toBe(path.join(instancesDir, 'work'));
|
||||
});
|
||||
|
||||
it('resolveCodexProfileDir correctly nests a different profile name', () => {
|
||||
const instancesDir = getCodexInstancesDir();
|
||||
const profileDir = resolveCodexProfileDir('personal');
|
||||
expect(profileDir).toBe(path.join(instancesDir, 'personal'));
|
||||
});
|
||||
|
||||
it('getSharedCodexConfigPath resolves under os.homedir() not getCcsDir()', () => {
|
||||
const result = getSharedCodexConfigPath();
|
||||
// Must equal os.homedir()/.codex/config.toml — uses real homedir, not getCcsDir()
|
||||
expect(result).toBe(path.join(os.homedir(), '.codex', 'config.toml'));
|
||||
// Must end with the Codex-canonical path fragment
|
||||
expect(result).toMatch(/\.codex[/\\]config\.toml$/);
|
||||
// Must NOT end inside the .ccs directory (i.e. not a CCS-owned path)
|
||||
expect(result).not.toContain(path.join('.ccs', 'codex'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
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';
|
||||
|
||||
let CodexProfileRegistry: new (registryPath?: string) => {
|
||||
createProfile(name: string, meta?: Record<string, unknown>): void;
|
||||
getProfile(name: string): Record<string, unknown>;
|
||||
updateProfile(name: string, partial: Record<string, unknown>): void;
|
||||
removeProfile(name: string): void;
|
||||
listProfiles(): string[];
|
||||
hasProfile(name: string): boolean;
|
||||
getDefault(): string | null;
|
||||
setDefault(name: string): void;
|
||||
clearDefault(): void;
|
||||
touchProfile(name: string): void;
|
||||
};
|
||||
|
||||
let tempDir: string;
|
||||
let ccsHome: string;
|
||||
let registryPath: string;
|
||||
|
||||
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-registry-test-'));
|
||||
ccsHome = path.join(tempDir, 'ccs-home');
|
||||
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
|
||||
process.env.CCS_HOME = ccsHome;
|
||||
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
|
||||
|
||||
const mod = await import('../../../src/codex-auth/codex-profile-registry');
|
||||
CodexProfileRegistry = mod.CodexProfileRegistry;
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — empty state', () => {
|
||||
it('returns empty list when registry file does not exist', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(reg.listProfiles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null default when registry file does not exist', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(reg.getDefault()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — create and get', () => {
|
||||
it('creates a profile and retrieves it by name', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
const profile = reg.getProfile('work');
|
||||
expect(profile.type).toBe('codex');
|
||||
expect(typeof profile.created).toBe('string');
|
||||
expect(profile.last_used).toBeNull();
|
||||
});
|
||||
|
||||
it('persists profile to disk as YAML with schema version', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
const raw = fs.readFileSync(registryPath, 'utf8');
|
||||
const parsed = yaml.load(raw) as Record<string, unknown>;
|
||||
expect(parsed.version).toBe('1.0');
|
||||
expect(typeof parsed.profiles).toBe('object');
|
||||
});
|
||||
|
||||
it('throws when creating a duplicate profile name', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
expect(() => reg.createProfile('work')).toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it('accepts optional metadata on create', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('personal', { email: 'me@example.com', plan_type: 'pro' });
|
||||
const profile = reg.getProfile('personal');
|
||||
expect(profile.email).toBe('me@example.com');
|
||||
expect(profile.plan_type).toBe('pro');
|
||||
});
|
||||
|
||||
it('hasProfile returns false before creation and true after', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(reg.hasProfile('work')).toBe(false);
|
||||
reg.createProfile('work');
|
||||
expect(reg.hasProfile('work')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — remove', () => {
|
||||
it('removes an existing profile', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
reg.removeProfile('work');
|
||||
expect(reg.listProfiles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when removing a non-existent profile', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(() => reg.removeProfile('ghost')).toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('clears default when the default profile is removed', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
reg.setDefault('work');
|
||||
expect(reg.getDefault()).toBe('work');
|
||||
reg.removeProfile('work');
|
||||
expect(reg.getDefault()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — default pointer', () => {
|
||||
it('setDefault throws when profile does not exist', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(() => reg.setDefault('ghost')).toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('setDefault and getDefault round-trip', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
reg.setDefault('work');
|
||||
expect(reg.getDefault()).toBe('work');
|
||||
});
|
||||
|
||||
it('clearDefault resets default to null', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
reg.setDefault('work');
|
||||
reg.clearDefault();
|
||||
expect(reg.getDefault()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — listProfiles', () => {
|
||||
it('returns all profile names', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
reg.createProfile('personal');
|
||||
const list = reg.listProfiles();
|
||||
expect(list).toContain('work');
|
||||
expect(list).toContain('personal');
|
||||
expect(list.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — corrupt YAML recovery', () => {
|
||||
it('returns empty state on corrupt YAML without throwing', () => {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, '{ invalid: yaml: content: [', { mode: 0o600 });
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(reg.listProfiles()).toEqual([]);
|
||||
expect(reg.getDefault()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — atomic write', () => {
|
||||
it('leaves no .tmp file after successful write', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
const dir = path.dirname(registryPath);
|
||||
const tmpFiles = fs.readdirSync(dir).filter((f) => f.includes('.tmp.'));
|
||||
expect(tmpFiles.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — touchProfile', () => {
|
||||
it('updates last_used timestamp', async () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
const before = new Date().toISOString();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
reg.touchProfile('work');
|
||||
const profile = reg.getProfile('work');
|
||||
expect(typeof profile.last_used).toBe('string');
|
||||
expect((profile.last_used as string) >= before).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — updateProfile', () => {
|
||||
it('merges partial updates into existing profile', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
reg.updateProfile('work', { email: 'updated@example.com', plan_type: 'plus' });
|
||||
const profile = reg.getProfile('work');
|
||||
expect(profile.email).toBe('updated@example.com');
|
||||
expect(profile.plan_type).toBe('plus');
|
||||
expect(profile.type).toBe('codex');
|
||||
});
|
||||
|
||||
it('throws when updating a non-existent profile', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
expect(() => reg.updateProfile('ghost', { email: 'x@x.com' })).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — registry file permissions', () => {
|
||||
it('writes registry file with mode 0o600', () => {
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
const stat = fs.statSync(registryPath);
|
||||
// On POSIX, check owner read/write only (0o600 = 0b110_000_000 = 384)
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// Lazy import so tests can run before implementation
|
||||
let decodeIdToken: (idToken: string) => { email?: string; plan_type?: string; account_id?: string };
|
||||
|
||||
// Fixture: real-shape JWT with nested claims
|
||||
// Payload (base64url): {"email":"user@example.com","https://api.openai.com/auth":{"chatgpt_plan_type":"pro","chatgpt_account_id":"4b0448c0-e4a2-4cc0-a70d-77065d613553"}}
|
||||
const VALID_NESTED_TOKEN = buildToken({
|
||||
email: 'user@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_account_id: '4b0448c0-e4a2-4cc0-a70d-77065d613553',
|
||||
},
|
||||
});
|
||||
|
||||
// Fixture: email only via profile fallback path
|
||||
const PROFILE_EMAIL_TOKEN = buildToken({
|
||||
'https://api.openai.com/profile': { email: 'profile@example.com' },
|
||||
'https://api.openai.com/auth': { chatgpt_plan_type: 'plus', chatgpt_account_id: 'acct-xyz' },
|
||||
});
|
||||
|
||||
// Fixture: neither top-level email nor profile email — both absent
|
||||
const NO_EMAIL_TOKEN = buildToken({
|
||||
sub: 'user-abc',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'free',
|
||||
chatgpt_account_id: 'acct-123',
|
||||
},
|
||||
});
|
||||
|
||||
// Fixture: missing plan_type in auth claim
|
||||
const MISSING_PLAN_TOKEN = buildToken({
|
||||
email: 'user@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct-no-plan',
|
||||
},
|
||||
});
|
||||
|
||||
// Fixture: missing account_id in auth claim
|
||||
const MISSING_ACCOUNT_ID_TOKEN = buildToken({
|
||||
email: 'user@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Fixture: no https://api.openai.com/auth claim at all
|
||||
const NO_AUTH_CLAIM_TOKEN = buildToken({
|
||||
email: 'user@example.com',
|
||||
sub: 'user-abc',
|
||||
});
|
||||
|
||||
function buildToken(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`;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/codex-auth/decode-id-token');
|
||||
decodeIdToken = mod.decodeIdToken;
|
||||
});
|
||||
|
||||
describe('decodeIdToken', () => {
|
||||
it('extracts email, plan_type, account_id from standard nested JWT', () => {
|
||||
const result = decodeIdToken(VALID_NESTED_TOKEN);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
expect(result.plan_type).toBe('pro');
|
||||
expect(result.account_id).toBe('4b0448c0-e4a2-4cc0-a70d-77065d613553');
|
||||
});
|
||||
|
||||
it('falls back to profile email when top-level email is absent', () => {
|
||||
const result = decodeIdToken(PROFILE_EMAIL_TOKEN);
|
||||
expect(result.email).toBe('profile@example.com');
|
||||
expect(result.plan_type).toBe('plus');
|
||||
expect(result.account_id).toBe('acct-xyz');
|
||||
});
|
||||
|
||||
it('returns {} for token with only 2 segments (malformed)', () => {
|
||||
const result = decodeIdToken('header.payload');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} for non-base64 garbage input', () => {
|
||||
const result = decodeIdToken('!!!.%%%.$$$');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object when email is absent in both paths', () => {
|
||||
const result = decodeIdToken(NO_EMAIL_TOKEN);
|
||||
expect(result.email).toBeUndefined();
|
||||
expect(result.plan_type).toBe('free');
|
||||
expect(result.account_id).toBe('acct-123');
|
||||
});
|
||||
|
||||
it('returns undefined plan_type when chatgpt_plan_type is absent', () => {
|
||||
const result = decodeIdToken(MISSING_PLAN_TOKEN);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
expect(result.plan_type).toBeUndefined();
|
||||
expect(result.account_id).toBe('acct-no-plan');
|
||||
});
|
||||
|
||||
it('returns undefined account_id when chatgpt_account_id is absent', () => {
|
||||
const result = decodeIdToken(MISSING_ACCOUNT_ID_TOKEN);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
expect(result.plan_type).toBe('pro');
|
||||
expect(result.account_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns partial result when https://api.openai.com/auth claim is absent', () => {
|
||||
const result = decodeIdToken(NO_AUTH_CLAIM_TOKEN);
|
||||
expect(result.email).toBe('user@example.com');
|
||||
expect(result.plan_type).toBeUndefined();
|
||||
expect(result.account_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not throw on empty string input', () => {
|
||||
expect(() => decodeIdToken('')).not.toThrow();
|
||||
expect(decodeIdToken('')).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user