diff --git a/src/codex-auth/codex-auth-dashboard-service.ts b/src/codex-auth/codex-auth-dashboard-service.ts index 0ee8bde3..699c973c 100644 --- a/src/codex-auth/codex-auth-dashboard-service.ts +++ b/src/codex-auth/codex-auth-dashboard-service.ts @@ -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; } diff --git a/src/codex-auth/codex-profile-registry.ts b/src/codex-auth/codex-profile-registry.ts index 1d0a75e9..c532a0ef 100644 --- a/src/codex-auth/codex-profile-registry.ts +++ b/src/codex-auth/codex-profile-registry.ts @@ -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(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 = {}): 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): 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 { diff --git a/src/codex-auth/commands/create-command.ts b/src/codex-auth/commands/create-command.ts index c79cd32c..0d975987 100644 --- a/src/codex-auth/commands/create-command.ts +++ b/src/codex-auth/commands/create-command.ts @@ -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}`)); diff --git a/src/codex-auth/commands/import-default-command.ts b/src/codex-auth/commands/import-default-command.ts index 9e70007c..20499689 100644 --- a/src/codex-auth/commands/import-default-command.ts +++ b/src/codex-auth/commands/import-default-command.ts @@ -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 | 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; } } diff --git a/src/codex-auth/commands/remove-command.ts b/src/codex-auth/commands/remove-command.ts index 7e275b01..af208aa8 100644 --- a/src/codex-auth/commands/remove-command.ts +++ b/src/codex-auth/commands/remove-command.ts @@ -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 { 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; + } +} diff --git a/src/codex-auth/decode-id-token.ts b/src/codex-auth/decode-id-token.ts index ad55bb40..d56670c0 100644 --- a/src/codex-auth/decode-id-token.ts +++ b/src/codex-auth/decode-id-token.ts @@ -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; + } +} diff --git a/src/codex-auth/shell-detect.ts b/src/codex-auth/shell-detect.ts index 741c18c1..b0635397 100644 --- a/src/codex-auth/shell-detect.ts +++ b/src/codex-auth/shell-detect.ts @@ -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)}`; diff --git a/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts b/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts index f8071dde..110b47f7 100644 --- a/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts +++ b/tests/unit/codex-auth/codex-auth-dashboard-service.test.ts @@ -34,6 +34,20 @@ function writeAuthJson(profileDir: string, idTokenPayload: Record { 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(); diff --git a/tests/unit/codex-auth/codex-profile-registry.test.ts b/tests/unit/codex-auth/codex-profile-registry.test.ts index ca6177f8..b526b186 100644 --- a/tests/unit/codex-auth/codex-profile-registry.test.ts +++ b/tests/unit/codex-auth/codex-profile-registry.test.ts @@ -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): 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 { + 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): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => child.once('exit', () => resolve())); +} diff --git a/tests/unit/codex-auth/commands/create-command.test.ts b/tests/unit/codex-auth/commands/create-command.test.ts index 62fa1d86..49b83ccc 100644 --- a/tests/unit/codex-auth/commands/create-command.test.ts +++ b/tests/unit/codex-auth/commands/create-command.test.ts @@ -34,6 +34,12 @@ async function makeCtx() { return { registry: reg, version: '0.0.0-test' }; } +function buildToken(payload: Record): 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; + } + ); + + 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'); + }); }); diff --git a/tests/unit/codex-auth/commands/import-default-command.test.ts b/tests/unit/codex-auth/commands/import-default-command.test.ts index 051cdc71..52efe75d 100644 --- a/tests/unit/codex-auth/commands/import-default-command.test.ts +++ b/tests/unit/codex-auth/commands/import-default-command.test.ts @@ -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', () => { diff --git a/tests/unit/codex-auth/commands/remove-command.test.ts b/tests/unit/codex-auth/commands/remove-command.test.ts index 3c07022f..1ed141a8 100644 --- a/tests/unit/codex-auth/commands/remove-command.test.ts +++ b/tests/unit/codex-auth/commands/remove-command.test.ts @@ -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'); + }); }); diff --git a/tests/unit/codex-auth/commands/use-command.test.ts b/tests/unit/codex-auth/commands/use-command.test.ts index 1be3fe23..33377645 100644 --- a/tests/unit/codex-auth/commands/use-command.test.ts +++ b/tests/unit/codex-auth/commands/use-command.test.ts @@ -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 () => { diff --git a/tests/unit/codex-auth/decode-id-token.test.ts b/tests/unit/codex-auth/decode-id-token.test.ts index 9992fddc..32a0bb08 100644 --- a/tests/unit/codex-auth/decode-id-token.test.ts +++ b/tests/unit/codex-auth/decode-id-token.test.ts @@ -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 { 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); + }); }); diff --git a/tests/unit/codex-auth/shell-detect.test.ts b/tests/unit/codex-auth/shell-detect.test.ts index 4f767079..11856556 100644 --- a/tests/unit/codex-auth/shell-detect.test.ts +++ b/tests/unit/codex-auth/shell-detect.test.ts @@ -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')).toBe( + 'set "CODEX_HOME=C:\\Users\\Kai & Co\\x|y"' + ); + }); + + it('escapes cmd expansion-sensitive characters', () => { + expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted"')).toBe( + 'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^""' ); }); });