mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
fix(codex-auth): harden profile review findings
This commit is contained in:
@@ -20,6 +20,7 @@ import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { decodeAccountIdentity } from './codex-account-identity';
|
||||
import { hasStructurallyValidIdToken } from './decode-id-token';
|
||||
import { getCodexAuthRegistryPath, getCodexInstancesDir } from './codex-profile-paths';
|
||||
import type { CodexProfileData } from './types';
|
||||
|
||||
@@ -99,14 +100,16 @@ function buildProfileEntry(name: string): CodexAuthProfileEntry {
|
||||
if (fs.existsSync(authJsonPath)) {
|
||||
// decodeAccountIdentity never throws; returns {} on any error
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
authValid = Object.keys(identity).length > 0 || _hasValidStructure(authJsonPath);
|
||||
authValid = Object.keys(identity).length > 0 || _hasStructurallyValidIdToken(authJsonPath);
|
||||
email = identity.email ?? null;
|
||||
plan = identity.plan_type ?? null;
|
||||
accountId = identity.account_id ?? null;
|
||||
logger.debug(
|
||||
'codex-auth.dashboard.decoded',
|
||||
`Decoded auth for profile=${name} email=${email ?? '(none)'}`
|
||||
);
|
||||
logger.debug('codex-auth.dashboard.decoded', 'Decoded auth profile summary', {
|
||||
profileName: name,
|
||||
hasEmail: email !== null,
|
||||
hasPlan: plan !== null,
|
||||
hasAccountId: accountId !== null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -129,15 +132,16 @@ function buildProfileEntry(name: string): CodexAuthProfileEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether auth.json has the expected structure (tokens.id_token present),
|
||||
* even if decoding yielded no display fields (e.g. no email in JWT).
|
||||
* Check whether auth.json has a parseable JWT payload, even if it has no
|
||||
* display fields. A non-empty but malformed token is not valid auth.
|
||||
* This sets authValid=true for valid-but-sparse tokens.
|
||||
*/
|
||||
function _hasValidStructure(authJsonPath: string): boolean {
|
||||
function _hasStructurallyValidIdToken(authJsonPath: string): boolean {
|
||||
try {
|
||||
const raw = fs.readFileSync(authJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { tokens?: { id_token?: string } };
|
||||
return typeof parsed?.tokens?.id_token === 'string' && parsed.tokens.id_token.length > 0;
|
||||
const idToken = parsed?.tokens?.id_token;
|
||||
return typeof idToken === 'string' && hasStructurallyValidIdToken(idToken);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
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');
|
||||
const REGISTRY_LOCK_STALE_MS = 10000;
|
||||
const REGISTRY_LOCK_RETRIES = 40;
|
||||
const REGISTRY_LOCK_RETRY_DELAY_MS = 50;
|
||||
|
||||
function emptyRegistry(): CodexProfileData {
|
||||
return { version: CODEX_PROFILE_SCHEMA_VERSION, default: null, profiles: {} };
|
||||
}
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
try {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
} catch {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
// Fall back for runtimes without Atomics.wait.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLockContentionError(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException | undefined)?.code === 'ELOCKED';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Writes are guarded by a registry-directory lock around the read-modify-write
|
||||
* cycle, then persisted atomically with tmp file + POSIX rename.
|
||||
*
|
||||
* Constructor accepts an optional registryPath for test isolation.
|
||||
*/
|
||||
@@ -79,6 +97,44 @@ export class CodexProfileRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private _withRegistryWriteLock<T>(callback: () => T): T {
|
||||
const dir = path.dirname(this.registryPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
let release: (() => void) | undefined;
|
||||
let lastLockError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= REGISTRY_LOCK_RETRIES; attempt++) {
|
||||
try {
|
||||
release = lockfile.lockSync(dir, { stale: REGISTRY_LOCK_STALE_MS }) as () => void;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (!isLockContentionError(err) || attempt === REGISTRY_LOCK_RETRIES) {
|
||||
throw err;
|
||||
}
|
||||
lastLockError = err;
|
||||
sleepSync(REGISTRY_LOCK_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
const msg = lastLockError instanceof Error ? lastLockError.message : 'unknown lock error';
|
||||
throw new Error(`Failed to acquire codex profile registry lock: ${msg}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort cleanup of orphan tmp files older than 1 hour (H3 mitigation).
|
||||
private _cleanOrphanTmpFiles(): void {
|
||||
const dir = path.dirname(this.registryPath);
|
||||
@@ -106,18 +162,20 @@ export class CodexProfileRegistry {
|
||||
// ── 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 });
|
||||
this._withRegistryWriteLock(() => {
|
||||
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 {
|
||||
@@ -130,26 +188,30 @@ export class CodexProfileRegistry {
|
||||
}
|
||||
|
||||
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);
|
||||
this._withRegistryWriteLock(() => {
|
||||
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 });
|
||||
this._withRegistryWriteLock(() => {
|
||||
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[] {
|
||||
@@ -167,18 +229,22 @@ export class CodexProfileRegistry {
|
||||
}
|
||||
|
||||
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);
|
||||
this._withRegistryWriteLock(() => {
|
||||
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);
|
||||
this._withRegistryWriteLock(() => {
|
||||
const data = this._read();
|
||||
data.default = null;
|
||||
this._write(data);
|
||||
});
|
||||
}
|
||||
|
||||
touchProfile(name: string): void {
|
||||
|
||||
@@ -155,14 +155,12 @@ async function _spawnLogin(
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
if (code === 0 && fs.existsSync(authJsonPath)) {
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
if (identity.email || identity.plan_type) {
|
||||
ctx.registry.updateProfile(profileName, {
|
||||
last_used: new Date().toISOString(),
|
||||
email: identity.email,
|
||||
plan_type: identity.plan_type ?? null,
|
||||
account_id: identity.account_id,
|
||||
});
|
||||
}
|
||||
ctx.registry.updateProfile(profileName, {
|
||||
last_used: new Date().toISOString(),
|
||||
email: identity.email,
|
||||
plan_type: identity.plan_type ?? null,
|
||||
account_id: identity.account_id,
|
||||
});
|
||||
const emailStr = identity.email ? ` as ${identity.email}` : '';
|
||||
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
|
||||
console.log(ok(`Logged in${emailStr}${planStr}`));
|
||||
|
||||
@@ -18,6 +18,7 @@ import { initUI } from '../../utils/ui';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { resolveCodexProfileDir, ensureSharedConfigSymlink, decodeIdToken } from '../index';
|
||||
import { hasStructurallyValidIdToken } from '../decode-id-token';
|
||||
import { parseArgs, getProfileNameError } from './types';
|
||||
import type { CodexCommandContext } from './types';
|
||||
|
||||
@@ -79,20 +80,15 @@ async function readAuthJsonSafe(authSrcPath: string): Promise<Record<string, unk
|
||||
);
|
||||
}
|
||||
|
||||
// Validate JWT shape: must have tokens.id_token as a 3-segment JWT
|
||||
// Validate JWT shape: must have tokens.id_token with a parseable JWT payload.
|
||||
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) {
|
||||
if (!hasStructurallyValidIdToken(idToken)) {
|
||||
// Torn write mid-JWT — retry
|
||||
throw new Error(`TORN_JWT: id_token has ${parts.length} segments (need 3)`);
|
||||
throw new Error('TORN_JWT: id_token payload is not parseable');
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { resolveCodexProfileDir } from '../codex-profile-paths';
|
||||
import { decodeAccountIdentity } from '../codex-account-identity';
|
||||
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
|
||||
import type { CodexCommandContext } from './types';
|
||||
import type { CodexProfileMetadata } from '../types';
|
||||
|
||||
export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
@@ -76,6 +77,7 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
|
||||
// Load cached email for impact summary
|
||||
const meta = registry.getProfile(profileName);
|
||||
const originalDefault = registry.getDefault();
|
||||
let emailStr = meta.email ?? null;
|
||||
if (!emailStr && authExists) {
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
@@ -108,9 +110,12 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dir then registry entry
|
||||
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
|
||||
try {
|
||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||
fs.renameSync(profileDir, stagedDeleteDir);
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'EACCES') {
|
||||
@@ -120,6 +125,74 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
throw err;
|
||||
}
|
||||
|
||||
registry.removeProfile(profileName);
|
||||
try {
|
||||
registry.removeProfile(profileName);
|
||||
} catch (err) {
|
||||
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
const preservedPath = restored ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
exitWithError(
|
||||
`Profile registry update failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
|
||||
ExitCode.GENERAL_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
const restoredDir = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
|
||||
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const registryNote = restoredRegistry
|
||||
? 'Profile registry entry was restored.'
|
||||
: 'Profile registry entry could not be restored automatically.';
|
||||
exitWithError(
|
||||
`Profile data delete failed; profile data was preserved at ${preservedPath}. ${registryNote}\n ${msg}`,
|
||||
ExitCode.GENERAL_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(ok(`Profile removed: ${profileName}`));
|
||||
}
|
||||
|
||||
function _restoreRegistryEntry(
|
||||
registry: CodexCommandContext['registry'],
|
||||
profileName: string,
|
||||
meta: CodexProfileMetadata,
|
||||
originalDefault: string | null
|
||||
): boolean {
|
||||
try {
|
||||
if (registry.hasProfile(profileName)) {
|
||||
registry.updateProfile(profileName, meta);
|
||||
} else {
|
||||
registry.createProfile(profileName, meta);
|
||||
}
|
||||
if (originalDefault === profileName) {
|
||||
registry.setDefault(profileName);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`[!] Profile data delete failed and automatic registry restore failed for "${profileName}".\n`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _restoreProfileDir(stagedDeleteDir: string, profileDir: string): boolean {
|
||||
try {
|
||||
if (fs.existsSync(stagedDeleteDir) && !fs.existsSync(profileDir)) {
|
||||
fs.renameSync(stagedDeleteDir, profileDir);
|
||||
return true;
|
||||
}
|
||||
return fs.existsSync(profileDir);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`[!] Registry update failed and automatic restore failed. Profile data remains at ${stagedDeleteDir}.\n`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CodexAccountIdentity } from './types';
|
||||
// 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';
|
||||
const BASE64URL_SEGMENT_RE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
interface OpenAIAuthClaim {
|
||||
chatgpt_plan_type?: string;
|
||||
@@ -29,6 +30,14 @@ function base64urlDecode(str: string): string {
|
||||
return Buffer.from(padded, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function isBase64UrlSegment(str: string): boolean {
|
||||
return str.length > 0 && BASE64URL_SEGMENT_RE.test(str);
|
||||
}
|
||||
|
||||
function decodeJsonSegment(str: string): unknown {
|
||||
return JSON.parse(base64urlDecode(str));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the payload of a JWT id_token without signature verification.
|
||||
* Returns only the display-safe fields: email, plan_type, account_id.
|
||||
@@ -39,13 +48,8 @@ function base64urlDecode(str: string): string {
|
||||
*/
|
||||
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 payload = decodeJwtPayload(idToken);
|
||||
if (!payload) return {};
|
||||
|
||||
const authClaim = payload[OPENAI_AUTH_CLAIM];
|
||||
const profileClaim = payload[OPENAI_PROFILE_CLAIM];
|
||||
@@ -69,3 +73,32 @@ export function decodeIdToken(idToken: string): CodexAccountIdentity {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function hasStructurallyValidIdToken(idToken: string): boolean {
|
||||
return decodeJwtPayload(idToken) !== null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(idToken: string): JwtPayload | null {
|
||||
try {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
if (!parts.every((part) => isBase64UrlSegment(part))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = decodeJsonSegment(parts[0] ?? '');
|
||||
if (!header || typeof header !== 'object' || Array.isArray(header)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = decodeJsonSegment(parts[1] ?? '');
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
return payload as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ function pwshDoubleQuote(value: string): string {
|
||||
return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a cmd.exe SET assignment. `set "KEY=value"` keeps command separators
|
||||
* like &, |, <, and > inside the assignment instead of executing them.
|
||||
*/
|
||||
function cmdSetQuote(value: string): string {
|
||||
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single env var export statement for the target shell.
|
||||
* Used by use-command to emit eval-safe lines.
|
||||
@@ -51,10 +59,7 @@ export function formatExport(shell: Shell, key: string, value: string): string {
|
||||
case 'pwsh':
|
||||
return `$env:${key} = ${pwshDoubleQuote(value)}`;
|
||||
case 'cmd':
|
||||
// cmd.exe: no quoting — values are used verbatim.
|
||||
// NOTE: cmd.exe cannot eval output from a node process natively.
|
||||
// Users should prefer PowerShell. See --help for details.
|
||||
return `set ${key}=${value}`;
|
||||
return `set "${key}=${cmdSetQuote(value)}"`;
|
||||
default:
|
||||
// bash / zsh
|
||||
return `export ${key}=${posixSingleQuote(value)}`;
|
||||
|
||||
@@ -34,6 +34,20 @@ function writeAuthJson(profileDir: string, idTokenPayload: Record<string, unknow
|
||||
});
|
||||
}
|
||||
|
||||
function writeRawAuthJson(profileDir: string, idToken: string): void {
|
||||
fs.writeFileSync(
|
||||
path.join(profileDir, 'auth.json'),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
id_token: idToken,
|
||||
access_token: 'access-token-should-not-appear',
|
||||
refresh_token: 'refresh-token-should-not-appear',
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
}
|
||||
|
||||
function writeRegistry(registryPath: string, data: unknown): void {
|
||||
const dir = path.dirname(registryPath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -177,6 +191,75 @@ describe('getCodexAuthProfilesSummary', () => {
|
||||
expect(broken?.accountId).toBeNull();
|
||||
});
|
||||
|
||||
it('sets authValid=false when id_token is non-empty but malformed', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const brokenDir = path.join(instancesDir, 'broken-jwt');
|
||||
fs.mkdirSync(brokenDir, { recursive: true });
|
||||
writeRawAuthJson(brokenDir, 'not-a-jwt');
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: broken-jwt\nprofiles:\n broken-jwt:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
const broken = result.profiles[0];
|
||||
expect(broken?.authValid).toBe(false);
|
||||
expect(broken?.email).toBeNull();
|
||||
expect(broken?.plan).toBeNull();
|
||||
expect(broken?.accountId).toBeNull();
|
||||
});
|
||||
|
||||
it('sets authValid=false when id_token contains invalid base64url characters', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const brokenDir = path.join(instancesDir, 'broken-base64url');
|
||||
fs.mkdirSync(brokenDir, { recursive: true });
|
||||
writeRawAuthJson(brokenDir, 'h.e30$.s');
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: broken-base64url\nprofiles:\n broken-base64url:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
const broken = result.profiles[0];
|
||||
expect(broken?.authValid).toBe(false);
|
||||
});
|
||||
|
||||
it('sets authValid=true for a valid but sparse JWT payload', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
const instancesDir = path.join(ccsDir, 'codex-instances');
|
||||
const sparseDir = path.join(instancesDir, 'sparse');
|
||||
fs.mkdirSync(sparseDir, { recursive: true });
|
||||
writeRawAuthJson(sparseDir, buildToken({}));
|
||||
|
||||
const registryPath = path.join(ccsDir, 'codex-profiles.yaml');
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
`version: "1.0"\ndefault: sparse\nprofiles:\n sparse:\n type: codex\n created: "2026-01-01T00:00:00Z"\n last_used: null\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
const result = await getCodexAuthProfilesSummary();
|
||||
const sparse = result.profiles[0];
|
||||
expect(sparse?.authValid).toBe(true);
|
||||
expect(sparse?.email).toBeNull();
|
||||
expect(sparse?.plan).toBeNull();
|
||||
expect(sparse?.accountId).toBeNull();
|
||||
});
|
||||
|
||||
it('sets authValid=false and nulls identity fields when auth.json is missing', async () => {
|
||||
const { getCodexAuthProfilesSummary, invalidateCodexAuthProfilesCache } = await importService();
|
||||
invalidateCodexAuthProfilesCache();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, 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';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
let CodexProfileRegistry: new (registryPath?: string) => {
|
||||
createProfile(name: string, meta?: Record<string, unknown>): void;
|
||||
@@ -41,6 +43,7 @@ afterEach(() => {
|
||||
process.env.CCS_HOME = ORIGINAL_CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — empty state', () => {
|
||||
@@ -212,3 +215,83 @@ describe('CodexProfileRegistry — registry file permissions', () => {
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexProfileRegistry — write lock', () => {
|
||||
it('serializes read-modify-write mutations through a registry lock', () => {
|
||||
const release = () => {};
|
||||
const lockSpy = spyOn(lockfile, 'lockSync').mockReturnValue(release);
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
|
||||
reg.createProfile('work');
|
||||
|
||||
expect(lockSpy).toHaveBeenCalled();
|
||||
const [lockTarget, options] = lockSpy.mock.calls[0] ?? [];
|
||||
expect(lockTarget).toBe(path.dirname(registryPath));
|
||||
expect(options).toMatchObject({ stale: 10000 });
|
||||
});
|
||||
|
||||
it('waits for a contended registry lock before writing', async () => {
|
||||
const registryDir = path.dirname(registryPath);
|
||||
const readyPath = path.join(tempDir, 'holder-ready');
|
||||
const holderScript = path.join(tempDir, 'hold-registry-lock.cjs');
|
||||
fs.writeFileSync(
|
||||
holderScript,
|
||||
`
|
||||
const fs = require('fs');
|
||||
const lockfile = require(process.argv[4]);
|
||||
const release = lockfile.lockSync(process.argv[2], { stale: 10000 });
|
||||
fs.writeFileSync(process.argv[3], String(process.pid));
|
||||
setTimeout(() => {
|
||||
release();
|
||||
process.exit(0);
|
||||
}, 150);
|
||||
setTimeout(() => process.exit(2), 5000);
|
||||
process.on('SIGTERM', () => {
|
||||
try { release(); } finally { process.exit(0); }
|
||||
});
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
holderScript,
|
||||
registryDir,
|
||||
readyPath,
|
||||
path.join(process.cwd(), 'node_modules', 'proper-lockfile'),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await waitForFile(readyPath);
|
||||
|
||||
const reg = new CodexProfileRegistry(registryPath);
|
||||
reg.createProfile('work');
|
||||
|
||||
expect(reg.hasProfile('work')).toBe(true);
|
||||
} finally {
|
||||
if (!child.killed) child.kill();
|
||||
await waitForChildExit(child);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs = 1000): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (!fs.existsSync(filePath)) {
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
throw new Error(`Timed out waiting for ${filePath}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForChildExit(child: ReturnType<typeof spawn>): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ async function makeCtx() {
|
||||
return { registry: reg, version: '0.0.0-test' };
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
/** Suppress console output during test. */
|
||||
function silenceConsole(): () => void {
|
||||
const origLog = console.log;
|
||||
@@ -306,4 +312,54 @@ describe('handleCreateCodex — auto-spawn login (D11)', () => {
|
||||
expect(fs.existsSync(profileDir)).toBe(true);
|
||||
expect(ctx.registry.hasProfile('faillogin')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists last_used and account_id when login token has account_id only', async () => {
|
||||
const detectorMod = await import('../../../../src/targets/codex-detector');
|
||||
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
|
||||
|
||||
spyOn(childProcess, 'spawn').mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_cmd: string, _args: string[], opts: any) => {
|
||||
const dir = (opts?.env?.CODEX_HOME as string) ?? '';
|
||||
if (dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'auth.json'),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
id_token: buildToken({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct-account-only',
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const ee = {
|
||||
on: (evt: string, cb: (n: number) => void) => {
|
||||
if (evt === 'exit') setImmediate(() => cb(0));
|
||||
return ee;
|
||||
},
|
||||
};
|
||||
return ee as ReturnType<typeof childProcess.spawn>;
|
||||
}
|
||||
);
|
||||
|
||||
const { handleCreateCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/create-command'
|
||||
);
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
try {
|
||||
await handleCreateCodex(ctx, ['accountonly']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const meta = ctx.registry.getProfile('accountonly');
|
||||
expect(meta.last_used).toBeTruthy();
|
||||
expect(meta.account_id).toBe('acct-account-only');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,6 +327,64 @@ describe('import-default — torn-write retry', () => {
|
||||
expect(exitCalled).toBe(true);
|
||||
expect(ctx.registry.hasProfile('torntest')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed 3-segment id_token payload', async () => {
|
||||
const authPath = path.join(legacyCodexHome, 'auth.json');
|
||||
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'header.not-json.sig' } }));
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
const origExit = process.exit;
|
||||
process.exit = () => {
|
||||
exitCalled = true;
|
||||
throw new Error('exit');
|
||||
};
|
||||
const restore = silenceConsole();
|
||||
try {
|
||||
await handleImportDefaultCodex(ctx, ['badjwt']);
|
||||
} catch {
|
||||
/* expected */
|
||||
} finally {
|
||||
restore();
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
expect(exitCalled).toBe(true);
|
||||
expect(ctx.registry.hasProfile('badjwt')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an id_token payload with invalid base64url characters', async () => {
|
||||
const authPath = path.join(legacyCodexHome, 'auth.json');
|
||||
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'h.e30$.s' } }));
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
const origExit = process.exit;
|
||||
process.exit = () => {
|
||||
exitCalled = true;
|
||||
throw new Error('exit');
|
||||
};
|
||||
const restore = silenceConsole();
|
||||
try {
|
||||
await handleImportDefaultCodex(ctx, ['bad-base64url']);
|
||||
} catch {
|
||||
/* expected */
|
||||
} finally {
|
||||
restore();
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
expect(exitCalled).toBe(true);
|
||||
expect(ctx.registry.hasProfile('bad-base64url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import-default — Codex running detection', () => {
|
||||
|
||||
@@ -191,4 +191,82 @@ describe('handleRemoveCodex — confirmation', () => {
|
||||
expect(promptCalled).toBe(false);
|
||||
expect(ctx.registry.hasProfile('skipconfirm')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves profile data when registry removal fails', async () => {
|
||||
const { handleRemoveCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/remove-command'
|
||||
);
|
||||
const ctx = await makeCtx('preserveme');
|
||||
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'preserveme');
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
|
||||
|
||||
spyOn(ctx.registry, 'removeProfile').mockImplementation(() => {
|
||||
throw new Error('registry write denied');
|
||||
});
|
||||
|
||||
let exitCode = -1;
|
||||
const origExit = process.exit;
|
||||
const origErr = console.error;
|
||||
process.exit = (code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error('exit');
|
||||
};
|
||||
console.error = () => {};
|
||||
|
||||
try {
|
||||
await handleRemoveCodex(ctx, ['preserveme', '--yes']);
|
||||
} catch {
|
||||
/* process.exit */
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
expect(exitCode).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(authJsonPath)).toBe(true);
|
||||
expect(ctx.registry.hasProfile('preserveme')).toBe(true);
|
||||
});
|
||||
|
||||
it('restores profile data and registry when final deletion fails', async () => {
|
||||
const { handleRemoveCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/remove-command'
|
||||
);
|
||||
const ctx = await makeCtx('restoreme');
|
||||
ctx.registry.setDefault('restoreme');
|
||||
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'restoreme');
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
|
||||
|
||||
const realRmSync = fs.rmSync;
|
||||
spyOn(fs, 'rmSync').mockImplementation((target, options) => {
|
||||
if (typeof target === 'string' && target.includes('.deleting.')) {
|
||||
throw new Error('delete denied');
|
||||
}
|
||||
return realRmSync(target, options);
|
||||
});
|
||||
|
||||
let exitCode = -1;
|
||||
const origExit = process.exit;
|
||||
const origErr = console.error;
|
||||
process.exit = (code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error('exit');
|
||||
};
|
||||
console.error = () => {};
|
||||
|
||||
try {
|
||||
await handleRemoveCodex(ctx, ['restoreme', '--yes']);
|
||||
} catch {
|
||||
/* process.exit */
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
expect(exitCode).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(authJsonPath)).toBe(true);
|
||||
expect(ctx.registry.hasProfile('restoreme')).toBe(true);
|
||||
expect(ctx.registry.getDefault()).toBe('restoreme');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,12 +143,12 @@ describe('handleUseCodex — shell syntax', () => {
|
||||
expect(stdout).toContain('$env:CCS_CODEX_PROFILE');
|
||||
});
|
||||
|
||||
it('cmd: set KEY=value (no quotes)', async () => {
|
||||
it('cmd: quoted set assignment syntax', async () => {
|
||||
const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command');
|
||||
const ctx = await makeCtxWithProfile('work');
|
||||
const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'cmd']));
|
||||
expect(stdout).toContain('set CODEX_HOME=');
|
||||
expect(stdout).toContain('set CCS_CODEX_PROFILE=work');
|
||||
expect(stdout).toContain('set "CODEX_HOME=');
|
||||
expect(stdout).toContain('set "CCS_CODEX_PROFILE=work"');
|
||||
});
|
||||
|
||||
it('invalid --shell value → stderr error, empty stdout', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 };
|
||||
let hasStructurallyValidIdToken: (idToken: string) => boolean;
|
||||
|
||||
// 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"}}
|
||||
@@ -62,6 +63,7 @@ function buildToken(payload: Record<string, unknown>): string {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/codex-auth/decode-id-token');
|
||||
decodeIdToken = mod.decodeIdToken;
|
||||
hasStructurallyValidIdToken = mod.hasStructurallyValidIdToken;
|
||||
});
|
||||
|
||||
describe('decodeIdToken', () => {
|
||||
@@ -121,4 +123,14 @@ describe('decodeIdToken', () => {
|
||||
expect(() => decodeIdToken('')).not.toThrow();
|
||||
expect(decodeIdToken('')).toEqual({});
|
||||
});
|
||||
|
||||
it('reports valid sparse JWT payloads as structurally valid', () => {
|
||||
expect(hasStructurallyValidIdToken(buildToken({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects JWT segments with invalid base64url characters', () => {
|
||||
const [header, payload, signature] = buildToken({}).split('.');
|
||||
expect(hasStructurallyValidIdToken(`${header}.${payload}$.${signature}`)).toBe(false);
|
||||
expect(hasStructurallyValidIdToken(`${header}=.${payload}.${signature}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,9 +95,21 @@ describe('formatExport — pwsh', () => {
|
||||
});
|
||||
|
||||
describe('formatExport — cmd', () => {
|
||||
it('uses set KEY=VALUE syntax without quotes', () => {
|
||||
it('uses quoted set assignment syntax', () => {
|
||||
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\foo\\.ccs\\codex-instances\\work')).toBe(
|
||||
'set CODEX_HOME=C:\\Users\\foo\\.ccs\\codex-instances\\work'
|
||||
'set "CODEX_HOME=C:\\Users\\foo\\.ccs\\codex-instances\\work"'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps cmd metacharacters inside the quoted set assignment', () => {
|
||||
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\Kai & Co\\x|y<z>')).toBe(
|
||||
'set "CODEX_HOME=C:\\Users\\Kai & Co\\x|y<z>"'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes cmd expansion-sensitive characters', () => {
|
||||
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted"')).toBe(
|
||||
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^""'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user