diff --git a/src/codex-auth/commands/import-default-command.ts b/src/codex-auth/commands/import-default-command.ts index d4352813..5fb9db08 100644 --- a/src/codex-auth/commands/import-default-command.ts +++ b/src/codex-auth/commands/import-default-command.ts @@ -3,7 +3,7 @@ * * Migrates legacy ~/.codex/auth.json into a named profile (non-destructive). * Implements C3 torn-write protection: read-with-retry + JWT-shape validation - * + pgrep Codex-running detection + atomic write. + * + current-user Codex-running detection + atomic write. * * Usage: ccsx auth import-default [--with-history] [--force] [--force-while-running] */ @@ -40,51 +40,37 @@ function sleep(ms: number): Promise { } /** - * Detect a running `codex` process via pgrep + ps validation (best-effort, never throws). - * Returns the PID string if found, null otherwise. + * Detect a running Codex CLI process from the current user's process table + * (best-effort, never throws). Returns the PID string if found, null otherwise. */ function detectCodexRunning(): string | null { try { - const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], { - encoding: 'utf8', - timeout: 2000, - }); - if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) { - return null; - } - - const pids = parsePgrepPids(result.stdout); - if (pids.length === 0) return null; - - const psResult = childProcess.spawnSync( + const result = childProcess.spawnSync( 'ps', - ['-p', pids.join(','), '-o', 'pid=', '-o', 'command='], + ['-A', '-o', 'pid=', '-o', 'uid=', '-o', 'command='], { encoding: 'utf8', timeout: 2000, } ); - if (psResult.status !== 0 || !psResult.stdout) return null; - return selectCodexPidFromPsOutput(psResult.stdout, pids); + if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) { + return null; + } + + return selectCodexPidFromPsOutput(result.stdout); } catch { return null; } } -function parsePgrepPids(stdout: string): string[] { - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid)); -} - -function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null { - const candidates = new Set(candidatePids); +function selectCodexPidFromPsOutput(stdout: string): string | null { + const currentUid = typeof process.getuid === 'function' ? process.getuid() : null; for (const line of stdout.split(/\r?\n/)) { - const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/); + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/); if (!match) continue; - const [, pid, command] = match; - if (!pid || !command || !candidates.has(pid)) continue; + const [, pid, uid, command] = match; + if (!pid || !uid || !command || pid === String(process.pid)) continue; + if (currentUid !== null && uid !== String(currentUid)) continue; if (isLikelyCodexProcessCommand(command)) return pid; } return null; 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 78107bbe..da1923b5 100644 --- a/tests/unit/codex-auth/commands/import-default-command.test.ts +++ b/tests/unit/codex-auth/commands/import-default-command.test.ts @@ -8,7 +8,7 @@ * - cliproxy-format source → rejects with clear message * - torn-write retry: truncated JSON twice then full → succeeds on 3rd read * - persistent torn state → clean error, not silent corruption - * - pgrep mock returning PID → warns + refuses without --force-while-running + * - process-table Codex PID → warns + refuses without --force-while-running * - --force-while-running bypasses pgrep check * - --with-history copies history.jsonl + sessions/ * - --with-history default false → not copied @@ -54,9 +54,9 @@ beforeEach(() => { process.env.CCS_HOME = ccsHome; process.env.LEGACY_CODEX_HOME = legacyCodexHome; - // Default: pgrep finds nothing (no Codex running). Tests that need a positive - // result override this per-test. Without this default, `pgrep -f codex` on a - // dev machine matches the Claude Code process itself and exits early with code 7. + // Default: process-table lookup finds nothing (no Codex running). Tests that + // need a positive result override this per-test. This keeps local developer + // processes from affecting import-default tests. spyOn(childProcess, 'spawnSync').mockReturnValue({ status: 1, stdout: '', @@ -78,9 +78,8 @@ afterEach(() => { }); async function makeCtx() { - const { CodexProfileRegistry } = await import( - '../../../../src/codex-auth/codex-profile-registry' - ); + const { CodexProfileRegistry } = + await import('../../../../src/codex-auth/codex-profile-registry'); return { registry: new CodexProfileRegistry(), version: '0.0.0-test', @@ -128,21 +127,10 @@ function captureOutput(): { stderr: string[]; restore: () => void } { }; } -function mockProcessTable(pgrepStdout: string, psStdout: string) { +function mockProcessTable(psStdout: string) { spyOn(childProcess, 'spawnSync').mockImplementation( // eslint-disable-next-line @typescript-eslint/no-explicit-any (cmd: string, _args: string[]): any => { - if (cmd === 'pgrep') { - return { - status: pgrepStdout.trim().length > 0 ? 0 : 1, - stdout: pgrepStdout, - stderr: '', - pid: 0, - output: [], - signal: null, - error: undefined, - }; - } if (cmd === 'ps') { return { status: psStdout.trim().length > 0 ? 0 : 1, @@ -171,9 +159,8 @@ function mockProcessTable(pgrepStdout: string, psStdout: string) { describe('import-default — missing legacy auth.json', () => { it('exits with clear error when ~/.codex/auth.json does not exist', async () => { - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -201,9 +188,8 @@ describe('import-default — option validation', () => { it('rejects unsupported flags before importing legacy auth', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCount = 0; @@ -252,9 +238,8 @@ describe('import-default — profile collision without --force', () => { // Write valid legacy auth fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); // Pre-create the profile ctx.registry.createProfile('myprofile'); @@ -284,9 +269,8 @@ describe('import-default — --force overwrites and creates backup', () => { // Write valid legacy auth fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); // First import (no --force needed since profile doesn't exist) @@ -338,9 +322,8 @@ describe('import-default — cliproxy-format rejection', () => { }); fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), cliproxyAuth); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -370,9 +353,8 @@ describe('import-default — torn-write retry', () => { // Write truncated JSON initially — simulates torn write mid-file fs.writeFileSync(authPath, '{truncated'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); // After 50ms (before 2nd retry at 100ms) replace with valid JSON @@ -397,9 +379,8 @@ describe('import-default — torn-write retry', () => { // Write persistently invalid JSON — all retries will fail fs.writeFileSync(authPath, '{always-truncated'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -426,9 +407,8 @@ describe('import-default — torn-write retry', () => { 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 { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -455,9 +435,8 @@ describe('import-default — torn-write retry', () => { 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 { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -485,9 +464,8 @@ describe('import-default — torn-write retry', () => { const [header, payload] = VALID_JWT.split('.'); fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: `${header}.${payload}.a` } })); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -512,14 +490,13 @@ describe('import-default — torn-write retry', () => { }); describe('import-default — Codex running detection', () => { - it('warns and refuses when pgrep finds a codex PID', async () => { + it('warns and refuses when process table finds a same-user Codex PID', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n'); + mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -546,11 +523,10 @@ describe('import-default — Codex running detection', () => { it('proceeds with --force-while-running even when Codex is running', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n'); + mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -567,11 +543,10 @@ describe('import-default — Codex running detection', () => { it('warns and refuses when Codex is running through a node shim', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('12345\n', '12345 /usr/bin/node /usr/local/bin/codex login\n'); + mockProcessTable(`12345 ${process.getuid()} /usr/bin/node /usr/local/bin/codex login\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -595,14 +570,13 @@ describe('import-default — Codex running detection', () => { expect(ctx.registry.hasProfile('nodeshim')).toBe(false); }); - it('ignores pgrep false positives that are not Codex executables', async () => { + it('ignores process-table false positives that are not Codex executables', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('11111\n', '11111 /usr/bin/node /tmp/codex-auth-helper.js\n'); + mockProcessTable(`11111 ${process.getuid()} /usr/bin/node /tmp/codex-auth-helper.js\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -614,6 +588,47 @@ describe('import-default — Codex running detection', () => { expect(ctx.registry.hasProfile('falsepositive')).toBe(true); }); + + it('ignores same-process codex-runtime invocation paths', async () => { + fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); + + mockProcessTable( + `${process.pid} ${process.getuid()} node /workspace/ccs/dist/bin/codex-runtime.js auth import-default self\n` + ); + + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleImportDefaultCodex(ctx, ['selfmatch']); + } finally { + restore(); + } + + expect(ctx.registry.hasProfile('selfmatch')).toBe(true); + }); + + it('ignores Codex processes owned by another uid', async () => { + fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); + + const otherUid = process.getuid() + 1; + mockProcessTable(`22222 ${otherUid} /usr/local/bin/codex login\n`); + + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleImportDefaultCodex(ctx, ['otheruid']); + } finally { + restore(); + } + + expect(ctx.registry.hasProfile('otheruid')).toBe(true); + }); }); describe('import-default — --with-history', () => { @@ -624,9 +639,8 @@ describe('import-default — --with-history', () => { fs.mkdirSync(sessionsDir, { recursive: true }); fs.writeFileSync(path.join(sessionsDir, 'sess1.json'), '{}'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -645,9 +659,8 @@ describe('import-default — --with-history', () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -666,9 +679,8 @@ describe('import-default — atomic write', () => { it('leaves no tmp file after successful import', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -689,9 +701,8 @@ describe('import-default — happy path end-to-end', () => { it('registers profile with decoded email in registry', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole();