diff --git a/cli/episodic-memory.js b/cli/episodic-memory.js index 08ebccc8..18058ef4 100755 --- a/cli/episodic-memory.js +++ b/cli/episodic-memory.js @@ -13,6 +13,7 @@ const args = process.argv.slice(3); function runScript(scriptPath, args) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [scriptPath, ...args], { + windowsHide: true, stdio: 'inherit' }); diff --git a/cli/install-runner.js b/cli/install-runner.js index 36b9ca08..750b66bb 100644 --- a/cli/install-runner.js +++ b/cli/install-runner.js @@ -136,6 +136,7 @@ export function runNpmInstall(pluginRoot, { spawn = defaultSpawn, lockHandle } = const child = spawn(npmCommand, ['install', '--no-audit', '--no-fund'], { cwd: pluginRoot, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, shell: isWindows, // On Windows, we need shell: true to find npm.cmd detached: !isWindows, }); diff --git a/cli/mcp-server-wrapper.js b/cli/mcp-server-wrapper.js index 22534dfa..0b0bc090 100755 --- a/cli/mcp-server-wrapper.js +++ b/cli/mcp-server-wrapper.js @@ -58,6 +58,7 @@ async function main() { // Use spawn with shell: false for better cross-platform compatibility const child = spawn(process.execPath, [mcpServerPath], { + windowsHide: true, stdio: 'inherit', shell: false }); diff --git a/dist/codex-executable.d.ts b/dist/codex-executable.d.ts new file mode 100644 index 00000000..fda01606 --- /dev/null +++ b/dist/codex-executable.d.ts @@ -0,0 +1,9 @@ +export interface CodexExecutable { + command: string; + identity: string; +} +/** Resolve native executables without a shell. Opaque scripts/wrappers fall + * back to uncached validation: their interpreter or delegated binary can + * change independently. The identity is only a cache key: subprocesses retain + * the caller's original command and argv[0], including native alias behavior. */ +export declare function resolveCodexExecutable(command: string): CodexExecutable | undefined; diff --git a/dist/codex-executable.js b/dist/codex-executable.js new file mode 100644 index 00000000..a0f07e6b --- /dev/null +++ b/dist/codex-executable.js @@ -0,0 +1,74 @@ +import fs from 'fs'; +import path from 'path'; +/** Resolve native executables without a shell. Opaque scripts/wrappers fall + * back to uncached validation: their interpreter or delegated binary can + * change independently. The identity is only a cache key: subprocesses retain + * the caller's original command and argv[0], including native alias behavior. */ +export function resolveCodexExecutable(command) { + const windows = process.platform === 'win32'; + const hasPath = path.isAbsolute(command) || command.includes('/') || (windows && command.includes('\\')); + if (!command || command === '.') + return undefined; + // Drive-relative paths, quoted PATH syntax and the Windows cwd-search + // policy are deliberately left uncached rather than approximating lookup. + if (windows && !path.isAbsolute(command) && command.includes(':')) + return undefined; + const pathKey = Object.keys(process.env).sort().find(key => windows ? key.toLowerCase() === 'path' : key === 'PATH'); + const searchPath = pathKey ? process.env[pathKey] : undefined; + // A missing PATH has platform-specific defaults. Leave that case to spawn. + if (!hasPath && searchPath === undefined) + return undefined; + if (windows && !hasPath && (/["']/.test(searchPath) || Object.keys(process.env).some(key => key.toLowerCase() === 'nodefaultcurrentdirectoryinexepath'))) + return undefined; + const name = path.basename(command); + const dot = name.indexOf('.'); + const nameHasExtension = dot >= 0 && dot < name.length - 1; + // libuv's exact-name flag for path-qualified extensionless executables has + // varied across runtimes; do not cache that ambiguous case. + if (windows && hasPath && !nameHasExtension) + return undefined; + const directories = hasPath ? [''] : [ + ...(windows ? [process.cwd()] : []), + ...searchPath.split(path.delimiter).filter(dir => !windows || dir.length > 0), + ]; + for (const directory of directories) { + const candidate = path.resolve(directory, command); + // libuv's shell-free Windows lookup considers .com/.exe, not PATHEXT. + const suffixBase = candidate.endsWith('.') ? candidate : `${candidate}.`; + const candidates = windows + ? [...(nameHasExtension ? [candidate] : []), `${suffixBase}com`, `${suffixBase}exe`] + : [candidate]; + for (const file of candidates) { + try { + const resolved = fs.realpathSync(file); + const stat = fs.statSync(resolved, { bigint: true }); + if (!stat.isFile()) + continue; + fs.accessSync(resolved, windows ? fs.constants.F_OK : fs.constants.X_OK); + // Only native executables have an identity bounded by this file. + const fd = fs.openSync(resolved, 'r'); + const magic = Buffer.alloc(4); + try { + fs.readSync(fd, magic, 0, 4, 0); + } + finally { + fs.closeSync(fd); + } + const native = windows ? magic.subarray(0, 2).toString() === 'MZ' + : ['7f454c46', 'feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca'].includes(magic.toString('hex')); + if (!native) + return undefined; + return { + command: resolved, + identity: [resolved, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join('|'), + }; + } + catch (error) { + // Unknown resolution/permissions must never reuse a successful check. + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') + return undefined; + } + } + } + return undefined; +} diff --git a/dist/parser.js b/dist/parser.js index dcc1c270..c2c90629 100644 --- a/dist/parser.js +++ b/dist/parser.js @@ -1037,12 +1037,8 @@ async function parseCursorConversation(filePath, projectName, archivePath) { */ export async function parseConversationFile(filePath) { // Extract project name from path (directory name before the .jsonl file) - const pathParts = filePath.split('/'); - let project = 'unknown'; - // Find the parent directory name (second to last part) - if (pathParts.length >= 2) { - project = pathParts[pathParts.length - 2]; - } + const parent = path.parse(filePath).dir; + const project = parent ? path.basename(parent) || 'unknown' : 'unknown'; const exchanges = await parseConversation(filePath, project, filePath); return { project, diff --git a/dist/summarizer.js b/dist/summarizer.js index 60719809..6fe628cf 100644 --- a/dist/summarizer.js +++ b/dist/summarizer.js @@ -4,6 +4,7 @@ import { SUMMARIZER_CONTEXT_MARKER } from './constants.js'; import { VERSION } from './version.js'; import { spawn } from 'child_process'; import { createInterface } from 'readline'; +import { resolveCodexExecutable } from './codex-executable.js'; import { codexVersionRequirementMessage, parseCodexCliVersion, versionMeetsMinimum, } from './codex-support.js'; /** Max chars of SDK `result` text kept on SummarizerSdkError (see #138). */ const SDK_ERROR_DETAIL_MAX = 300; @@ -394,6 +395,7 @@ function appServerTimeoutMs() { function readCommandOutput(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { + windowsHide: true, env: getApiEnv(), stdio: ['ignore', 'pipe', 'pipe'] }); @@ -415,14 +417,57 @@ function readCommandOutput(command, args) { }); }); } -async function assertSupportedCodexVersion(command) { +// Worker-local only: share in-flight checks, retain successful checks, and retry +// every failure. Bound entries so callers with custom version arguments cannot +// grow a long-lived worker indefinitely. +const supportedCodexVersions = new Map(); +const MAX_CODEX_VERSION_ENTRIES = 16; +class CodexExecutableChangedError extends Error { +} +async function assertSupportedCodexVersion(command, retryIdentityChange = true) { if (command.skipVersionCheck) { return; } - const output = await readCommandOutput(command.command, command.versionArgs || ['--version']); - const version = parseCodexCliVersion(output); - if (!version || !versionMeetsMinimum(version)) { - throw new Error(codexVersionRequirementMessage(output)); + const executable = resolveCodexExecutable(command.command); + const versionArgs = command.versionArgs || ['--version']; + const validate = async () => { + const output = await readCommandOutput(command.command, versionArgs); + const version = parseCodexCliVersion(output); + if (!version || !versionMeetsMinimum(version)) { + throw new Error(codexVersionRequirementMessage(output)); + } + if (executable && resolveCodexExecutable(command.command)?.identity !== executable.identity) { + throw new CodexExecutableChangedError('Codex executable changed during compatibility validation; retry.'); + } + }; + if (!executable) { + await validate(); + return; + } + const key = JSON.stringify([command.command, executable.command, versionArgs]); + let entry = supportedCodexVersions.get(key); + if (!entry || entry.identity !== executable.identity) { + entry = { identity: executable.identity, check: validate() }; + supportedCodexVersions.delete(key); + supportedCodexVersions.set(key, entry); + if (supportedCodexVersions.size > MAX_CODEX_VERSION_ENTRIES) { + supportedCodexVersions.delete(supportedCodexVersions.keys().next().value); + } + } + try { + await entry.check; + } + catch (error) { + // An older failed check must not evict a replacement executable's entry. + if (supportedCodexVersions.get(key) === entry) + supportedCodexVersions.delete(key); + // Native installers/Windows can update metadata on first execution. Repeat + // the full check once; concurrent waiters join the same replacement entry. + // Repeated instability and ordinary version/launch failures still reject. + if (retryIdentityChange && error instanceof CodexExecutableChangedError) { + return assertSupportedCodexVersion(command, false); + } + throw error; } } function requireThreadId(result, method) { @@ -443,6 +488,7 @@ export async function runCodexCommand(command) { await assertSupportedCodexVersion(command); return new Promise((resolve, reject) => { const child = spawn(command.command, command.args, { + windowsHide: true, env: getApiEnv(), stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/dist/sync-cli.js b/dist/sync-cli.js index 26172c70..72e411be 100644 --- a/dist/sync-cli.js +++ b/dist/sync-cli.js @@ -135,6 +135,7 @@ if (isBackground) { process.argv[1], // This script ...filteredArgs ], { + windowsHide: true, detached: true, stdio: ['ignore', logFd, logFd] }); diff --git a/src/codex-executable.ts b/src/codex-executable.ts new file mode 100644 index 00000000..ee18b64b --- /dev/null +++ b/src/codex-executable.ts @@ -0,0 +1,66 @@ +import fs from 'fs'; +import path from 'path'; + +export interface CodexExecutable { + command: string; + identity: string; +} + +/** Resolve native executables without a shell. Opaque scripts/wrappers fall + * back to uncached validation: their interpreter or delegated binary can + * change independently. The identity is only a cache key: subprocesses retain + * the caller's original command and argv[0], including native alias behavior. */ +export function resolveCodexExecutable(command: string): CodexExecutable | undefined { + const windows = process.platform === 'win32'; + const hasPath = path.isAbsolute(command) || command.includes('/') || (windows && command.includes('\\')); + if (!command || command === '.') return undefined; + // Drive-relative paths, quoted PATH syntax and the Windows cwd-search + // policy are deliberately left uncached rather than approximating lookup. + if (windows && !path.isAbsolute(command) && command.includes(':')) return undefined; + const pathKey = Object.keys(process.env).sort().find(key => windows ? key.toLowerCase() === 'path' : key === 'PATH'); + const searchPath = pathKey ? process.env[pathKey] : undefined; + // A missing PATH has platform-specific defaults. Leave that case to spawn. + if (!hasPath && searchPath === undefined) return undefined; + if (windows && !hasPath && (/["']/.test(searchPath!) || Object.keys(process.env).some(key => key.toLowerCase() === 'nodefaultcurrentdirectoryinexepath'))) return undefined; + const name = path.basename(command); + const dot = name.indexOf('.'); + const nameHasExtension = dot >= 0 && dot < name.length - 1; + // libuv's exact-name flag for path-qualified extensionless executables has + // varied across runtimes; do not cache that ambiguous case. + if (windows && hasPath && !nameHasExtension) return undefined; + const directories = hasPath ? [''] : [ + ...(windows ? [process.cwd()] : []), + ...searchPath!.split(path.delimiter).filter(dir => !windows || dir.length > 0), + ]; + for (const directory of directories) { + const candidate = path.resolve(directory, command); + // libuv's shell-free Windows lookup considers .com/.exe, not PATHEXT. + const suffixBase = candidate.endsWith('.') ? candidate : `${candidate}.`; + const candidates = windows + ? [...(nameHasExtension ? [candidate] : []), `${suffixBase}com`, `${suffixBase}exe`] + : [candidate]; + for (const file of candidates) { + try { + const resolved = fs.realpathSync(file); + const stat = fs.statSync(resolved, { bigint: true }); + if (!stat.isFile()) continue; + fs.accessSync(resolved, windows ? fs.constants.F_OK : fs.constants.X_OK); + // Only native executables have an identity bounded by this file. + const fd = fs.openSync(resolved, 'r'); + const magic = Buffer.alloc(4); + try { fs.readSync(fd, magic, 0, 4, 0); } finally { fs.closeSync(fd); } + const native = windows ? magic.subarray(0, 2).toString() === 'MZ' + : ['7f454c46', 'feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca'].includes(magic.toString('hex')); + if (!native) return undefined; + return { + command: resolved, + identity: [resolved, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join('|'), + }; + } catch (error: any) { + // Unknown resolution/permissions must never reuse a successful check. + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return undefined; + } + } + } + return undefined; +} diff --git a/src/parser.ts b/src/parser.ts index a416a113..8235fd6c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1262,13 +1262,8 @@ export async function parseConversationFile(filePath: string): Promise<{ exchanges: ConversationExchange[]; }> { // Extract project name from path (directory name before the .jsonl file) - const pathParts = filePath.split('/'); - let project = 'unknown'; - - // Find the parent directory name (second to last part) - if (pathParts.length >= 2) { - project = pathParts[pathParts.length - 2]; - } + const parent = path.parse(filePath).dir; + const project = parent ? path.basename(parent) || 'unknown' : 'unknown'; const exchanges = await parseConversation(filePath, project, filePath); diff --git a/src/summarizer.ts b/src/summarizer.ts index d7e3cc04..9aeeafa6 100644 --- a/src/summarizer.ts +++ b/src/summarizer.ts @@ -5,6 +5,7 @@ import { SUMMARIZER_CONTEXT_MARKER } from './constants.js'; import { VERSION } from './version.js'; import { spawn } from 'child_process'; import { createInterface } from 'readline'; +import { resolveCodexExecutable } from './codex-executable.js'; import { codexVersionRequirementMessage, parseCodexCliVersion, @@ -455,6 +456,7 @@ function appServerTimeoutMs(): number { function readCommandOutput(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { + windowsHide: true, env: getApiEnv(), stdio: ['ignore', 'pipe', 'pipe'] }); @@ -477,15 +479,57 @@ function readCommandOutput(command: string, args: string[]): Promise { }); } -async function assertSupportedCodexVersion(command: CodexSummarizerCommand): Promise { +// Worker-local only: share in-flight checks, retain successful checks, and retry +// every failure. Bound entries so callers with custom version arguments cannot +// grow a long-lived worker indefinitely. +const supportedCodexVersions = new Map }>(); +const MAX_CODEX_VERSION_ENTRIES = 16; +class CodexExecutableChangedError extends Error {} + +async function assertSupportedCodexVersion(command: CodexSummarizerCommand, retryIdentityChange = true): Promise { if (command.skipVersionCheck) { return; } - const output = await readCommandOutput(command.command, command.versionArgs || ['--version']); - const version = parseCodexCliVersion(output); - if (!version || !versionMeetsMinimum(version)) { - throw new Error(codexVersionRequirementMessage(output)); + const executable = resolveCodexExecutable(command.command); + const versionArgs = command.versionArgs || ['--version']; + const validate = async () => { + const output = await readCommandOutput(command.command, versionArgs); + const version = parseCodexCliVersion(output); + if (!version || !versionMeetsMinimum(version)) { + throw new Error(codexVersionRequirementMessage(output)); + } + if (executable && resolveCodexExecutable(command.command)?.identity !== executable.identity) { + throw new CodexExecutableChangedError('Codex executable changed during compatibility validation; retry.'); + } + }; + if (!executable) { + await validate(); + return; + } + + const key = JSON.stringify([command.command, executable.command, versionArgs]); + let entry = supportedCodexVersions.get(key); + if (!entry || entry.identity !== executable.identity) { + entry = { identity: executable.identity, check: validate() }; + supportedCodexVersions.delete(key); + supportedCodexVersions.set(key, entry); + if (supportedCodexVersions.size > MAX_CODEX_VERSION_ENTRIES) { + supportedCodexVersions.delete(supportedCodexVersions.keys().next().value!); + } + } + try { + await entry.check; + } catch (error) { + // An older failed check must not evict a replacement executable's entry. + if (supportedCodexVersions.get(key) === entry) supportedCodexVersions.delete(key); + // Native installers/Windows can update metadata on first execution. Repeat + // the full check once; concurrent waiters join the same replacement entry. + // Repeated instability and ordinary version/launch failures still reject. + if (retryIdentityChange && error instanceof CodexExecutableChangedError) { + return assertSupportedCodexVersion(command, false); + } + throw error; } } @@ -510,6 +554,7 @@ export async function runCodexCommand(command: CodexSummarizerCommand): Promise< return new Promise((resolve, reject) => { const child = spawn(command.command, command.args, { + windowsHide: true, env: getApiEnv(), stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/src/sync-cli.ts b/src/sync-cli.ts index 297b20f8..584466c7 100644 --- a/src/sync-cli.ts +++ b/src/sync-cli.ts @@ -148,6 +148,7 @@ if (isBackground) { process.argv[1], // This script ...filteredArgs ], { + windowsHide: true, detached: true, stdio: ['ignore', logFd, logFd] }); diff --git a/test/cli-hidden-spawn-fixture.cjs b/test/cli-hidden-spawn-fixture.cjs new file mode 100644 index 00000000..ed5b3e20 --- /dev/null +++ b/test/cli-hidden-spawn-fixture.cjs @@ -0,0 +1,45 @@ +// Intercepts the real ESM CLI's child_process.spawn before importing it. +const { EventEmitter } = require('node:events'); +const childProcess = require('node:child_process'); +const { syncBuiltinESMExports } = require('node:module'); +const { pathToFileURL } = require('node:url'); +const { dirname, join } = require('node:path'); +const { tmpdir } = require('node:os'); +const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('node:fs'); + +const target = process.argv[2]; +const mode = process.argv[3]; +if (!target || !['command', 'mcp'].includes(mode)) process.exit(2); +const sourceRoot = dirname(dirname(target)); +const pluginRoot = mkdtempSync(join(tmpdir(), 'episodic-hidden-spawn-')); +process.on('exit', () => rmSync(pluginRoot, { recursive: true, force: true })); +for (const pkg of [ + '@anthropic-ai/claude-agent-sdk', '@huggingface/transformers', + 'better-sqlite3', 'onnxruntime-node', 'proper-lockfile', 'sqlite-vec', +]) { + const packageDir = join(pluginRoot, 'node_modules', pkg); + mkdirSync(packageDir, { recursive: true }); + writeFileSync(join(packageDir, 'package.json'), '{}'); +} +mkdirSync(join(pluginRoot, 'dist'), { recursive: true }); +writeFileSync(join(pluginRoot, 'dist/mcp-server.js'), ''); +process.env.CLAUDE_PLUGIN_ROOT = pluginRoot; +process.argv = [process.execPath, target, ...(mode === 'command' ? ['show', '--help'] : [])]; +childProcess.spawn = (command, args, options) => { + console.log('SPAWN_OPTIONS=' + JSON.stringify({ command, args, options })); + const child = new EventEmitter(); + child.kill = () => true; + process.nextTick(() => child.emit('exit', 0, null)); + return child; +}; +syncBuiltinESMExports(); +(async () => { + if (mode === 'mcp') { + const { findMissingDeps } = await import(pathToFileURL(join(sourceRoot, 'cli/install-check.js')).href); + if (findMissingDeps(pluginRoot).length) throw new Error('Fixture dependencies missing; refusing install path'); + } + await import(pathToFileURL(target).href); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/codex-version-cache.test.ts b/test/codex-version-cache.test.ts new file mode 100644 index 00000000..71182280 --- /dev/null +++ b/test/codex-version-cache.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const observed = vi.hoisted(() => ({ calls: [] as any[], closed: [] as Promise[] })); +vi.mock('child_process', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, spawn: (...args: Parameters) => { + observed.calls.push(args); + const child = actual.spawn(...args); + observed.closed.push(new Promise(resolve => child.once('close', resolve))); + return child; + } }; +}); +import { runCodexCommand } from '../src/summarizer.js'; +import { resolveCodexExecutable } from '../src/codex-executable.js'; +import * as executableResolver from '../src/codex-executable.js'; + +const fixture = fileURLToPath(new URL('./fixtures/codex-cache-server.cjs', import.meta.url)); +const dirs: string[] = []; +function temp() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-cache-')); dirs.push(dir); return dir; } +function request(versionArgs = ['--version'], command = process.execPath) { + return { command, args: [fixture], versionArgs, sessionId: 'synthetic-session', prompt: 'synthetic prompt' }; +} +function checks() { return observed.calls.filter(call => call[1][0] !== fixture); } +async function settle() { await Promise.all(observed.closed); } +afterEach(async () => { + await settle(); + observed.calls.length = 0; observed.closed.length = 0; + vi.unstubAllEnvs(); + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('worker-local Codex compatibility cache', () => { + it('launches one version check and ten independent ephemeral app-servers for a serial batch', async () => { + const req = request(['-e', "console.log('codex-cli 0.150.0') // serial"]); + for (let i = 0; i < 10; i++) expect(await runCodexCommand(req)).toBe('synthetic summary'); + expect(checks()).toHaveLength(1); + expect(observed.calls).toHaveLength(11); + for (const call of observed.calls) { + expect(call[2].windowsHide).toBe(true); + expect(call[2].env.EPISODIC_MEMORY_SUMMARIZER_GUARD).toBe('1'); + } + }); + it('coalesces concurrent version requests while keeping every app-server independent', async () => { + const req = request(['-e', "setTimeout(() => console.log('codex-cli 0.150.0'), 80) // concurrent"]); + expect(await Promise.all(Array.from({ length: 8 }, () => runCodexCommand(req)))).toHaveLength(8); + expect(checks()).toHaveLength(1); + expect(observed.calls).toHaveLength(9); + }); + it('does not cache unsupported, malformed, or failed validations', async () => { + for (const code of ["console.log('codex-cli 0.129.0')", "console.log('unparseable')", 'process.exit(2)']) { + const req = request(['-e', code]); + for (let i = 0; i < 2; i++) await expect(runCodexCommand(req)).rejects.toThrow(); + } + expect(checks()).toHaveLength(6); + expect(observed.calls).toHaveLength(6); + }); + it('coalesces a failed wave and retries successfully with identical arguments', async () => { + const flag = path.join(temp(), 'retry-flag'); + const code = `const fs=require('fs'); if(fs.existsSync(${JSON.stringify(flag)})) console.log('codex-cli 0.150.0'); else process.exit(2);`; + const req = request(['-e', code]); + const failed = await Promise.allSettled([runCodexCommand(req), runCodexCommand(req)]); + expect(failed.every(result => result.status === 'rejected')).toBe(true); + expect(checks()).toHaveLength(1); + fs.writeFileSync(flag, 'retry'); + await runCodexCommand(req); + await runCodexCommand(req); + expect(checks()).toHaveLength(2); + expect(observed.calls).toHaveLength(4); + }); + it('preserves skipVersionCheck without poisoning later checks', async () => { + const req = request(['-e', "console.log('codex-cli 0.129.1')"]); + await runCodexCommand({ ...req, skipVersionCheck: true }); + expect(checks()).toHaveLength(0); + await expect(runCodexCommand(req)).rejects.toThrow(/0.129.1/); + expect(checks()).toHaveLength(1); + }); + it('invalidates after same-path executable replacement, including restored size/mtime', async () => { + const executable = path.join(temp(), process.platform === 'win32' ? 'fixture.exe' : 'fixture'); + fs.copyFileSync(process.execPath, executable); + fs.chmodSync(executable, 0o755); + fs.utimesSync(executable, new Date(), new Date()); + const req = request(['-e', "console.log('codex-cli 0.150.0') // replacement"], executable); + await runCodexCommand(req); + await settle(); + const coldChecks = checks().length; + expect(coldChecks).toBeLessThanOrEqual(2); + await runCodexCommand(req); + await settle(); + expect(checks()).toHaveLength(coldChecks); + const before = fs.statSync(executable); + fs.renameSync(executable, `${executable}.old`); + fs.copyFileSync(process.execPath, executable); + fs.chmodSync(executable, 0o755); + fs.utimesSync(executable, before.atime, before.mtime); + await runCodexCommand(req); + expect(checks().length).toBeGreaterThan(coldChecks); + }); + it('resolves PATH changes and relative commands against the current directory', async () => { + const a = temp(), b = temp(); + const name = process.platform === 'win32' ? 'synthetic-codex.exe' : 'synthetic-codex'; + for (const dir of [a, b]) { + fs.copyFileSync(process.execPath, path.join(dir, name)); + fs.chmodSync(path.join(dir, name), 0o755); + fs.utimesSync(path.join(dir, name), new Date(), new Date()); + } + // Use the existing canonical key so Windows duplicate-key ordering is preserved. + const key = Object.keys(process.env).sort().find(k => k.toLowerCase() === 'path') ?? 'PATH'; + vi.stubEnv(key, a); + await runCodexCommand(request(['-e', "console.log('codex-cli 0.150.0') // path"], 'synthetic-codex')); + vi.stubEnv(key, b); + await runCodexCommand(request(['-e', "console.log('codex-cli 0.150.0') // path"], 'synthetic-codex')); + expect(checks()[0][0]).toBe('synthetic-codex'); + expect(checks().at(-1)[0]).toBe('synthetic-codex'); + const cwd = process.cwd(); + try { + process.chdir(a); + expect(resolveCodexExecutable(`./${name}`)?.command).toBe(fs.realpathSync(path.join(a, name))); + process.chdir(b); + expect(resolveCodexExecutable(`./${name}`)?.command).toBe(fs.realpathSync(path.join(b, name))); + } finally { process.chdir(cwd); } + }); + it('leaves opaque scripts and missing commands uncached', () => { + const script = path.join(temp(), 'wrapper'); + fs.writeFileSync(script, '#!/bin/sh\nexec codex "$@"\n'); + fs.chmodSync(script, 0o755); + expect(resolveCodexExecutable(script)).toBeUndefined(); + expect(resolveCodexExecutable(`${script}-missing`)).toBeUndefined(); + }); + it('invalidates a retargeted directory alias and in-place metadata changes', () => { + const root = temp(); + const a = path.join(root, 'a'), b = path.join(root, 'b'); + fs.mkdirSync(a); fs.mkdirSync(b); + const name = path.basename(process.execPath); + fs.copyFileSync(process.execPath, path.join(a, name)); + fs.copyFileSync(process.execPath, path.join(b, name)); + fs.chmodSync(path.join(a, name), 0o755); + fs.chmodSync(path.join(b, name), 0o755); + const alias = path.join(root, 'current'); + fs.symlinkSync(a, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const first = resolveCodexExecutable(path.join(alias, name)); + fs.unlinkSync(alias); + fs.symlinkSync(b, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const second = resolveCodexExecutable(path.join(alias, name)); + expect(first?.command).toBe(fs.realpathSync(path.join(a, name))); + expect(second?.command).toBe(fs.realpathSync(path.join(b, name))); + expect(first?.identity).not.toBe(second?.identity); + const copy = path.join(root, name); + fs.copyFileSync(process.execPath, copy); + fs.chmodSync(copy, 0o755); + const before = resolveCodexExecutable(copy)?.identity; + fs.utimesSync(copy, new Date(), new Date(Date.now() + 5000)); + expect(resolveCodexExecutable(copy)?.identity).not.toBe(before); + }); + it('preserves the caller native alias and argv0 for version and app-server launches', async () => { + const dir = temp(); + const alias = path.join(dir, 'node-alias'); + fs.symlinkSync(path.dirname(process.execPath), alias, process.platform === 'win32' ? 'junction' : 'dir'); + const command = path.join(alias, path.basename(process.execPath)); + vi.stubEnv('SYNTHETIC_EXPECT_ARGV0', command); + const code = `if(process.argv0 !== ${JSON.stringify(command)}) throw Error('changed argv0'); console.log('codex-cli 0.150.0');`; + expect(await runCodexCommand(request(['-e', code], command))).toBe('synthetic summary'); + expect(observed.calls.every(call => call[0] === command)).toBe(true); + expect(resolveCodexExecutable(command)?.command).toBe(fs.realpathSync(process.execPath)); + }); + it.skipIf(process.platform !== 'win32')('matches native Windows extension fallback and leaves ambiguous search uncached', async () => { + const dir = temp(); + const file = path.join(dir, 'synthetic-codex.custom.exe'); + fs.copyFileSync(process.execPath, file); + const key = Object.keys(process.env).sort().find(k => k.toLowerCase() === 'path') ?? 'PATH'; + vi.stubEnv(key, dir); + for (const policyKey of Object.keys(process.env).filter(k => k.toLowerCase() === 'nodefaultcurrentdirectoryinexepath')) vi.stubEnv(policyKey, undefined); + expect(resolveCodexExecutable('synthetic-codex.custom')?.command).toBe(fs.realpathSync(file)); + const { spawnSync } = await import('child_process'); + const actual = spawnSync('synthetic-codex.custom', ['-e', 'console.log(process.execPath)'], { windowsHide: true, encoding: 'utf8' }); + expect(actual.status).toBe(0); + expect(fs.realpathSync(actual.stdout.trim())).toBe(fs.realpathSync(file)); + vi.stubEnv('NoDefaultCurrentDirectoryInExePath', '1'); + expect(resolveCodexExecutable('synthetic-codex.custom')).toBeUndefined(); + expect(resolveCodexExecutable('C:codex.exe')).toBeUndefined(); + }); + it('coalesces the bounded identity retry and rejects repeated instability', async () => { + let resolution = 0; + const spy = vi.spyOn(executableResolver, 'resolveCodexExecutable').mockImplementation(() => ({ command: 'synthetic-binary', identity: ++resolution === 1 ? 'old' : 'new' })); + try { + const req = request(['-e', "setTimeout(() => console.log('codex-cli 0.150.0'), 60) // identity retry"]); + await Promise.all([runCodexCommand(req), runCodexCommand(req)]); + expect(checks()).toHaveLength(2); + expect(observed.calls).toHaveLength(4); + await runCodexCommand(req); + expect(checks()).toHaveLength(2); + spy.mockImplementation(() => ({ command: 'unstable-binary', identity: String(++resolution) })); + const start = checks().length; + await expect(runCodexCommand(request(['-e', "console.log('codex-cli 0.150.0') // unstable"]))).rejects.toThrow('changed during compatibility'); + expect(checks()).toHaveLength(start + 2); + } finally { spy.mockRestore(); } + }); + it('rejects an unsupported replacement during retry before any app-server starts', async () => { + let resolution = 0; + const spy = vi.spyOn(executableResolver, 'resolveCodexExecutable').mockImplementation(() => ({ command: 'replacement-binary', identity: ++resolution === 1 ? 'old' : 'new' })); + const marker = path.join(temp(), 'version-replaced'); + const code = `const fs=require('fs'); const p=${JSON.stringify(marker)}; if(fs.existsSync(p)) console.log('codex-cli 0.129.0'); else {fs.writeFileSync(p,'changed'); console.log('codex-cli 0.150.0');}`; + try { + await expect(runCodexCommand(request(['-e', code]))).rejects.toThrow('found 0.129.0'); + expect(checks()).toHaveLength(2); + expect(observed.calls).toHaveLength(2); + } finally { spy.mockRestore(); } + }); +}); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index d01f776b..70a70883 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, chmodSync } from 'fs'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { pathToFileURL } from 'url'; @@ -67,18 +67,11 @@ describe('file-lock — proper-lockfile wrapper (#97)', () => { }); it('propagates unexpected I/O errors instead of masking them as "lock contention"', () => { - // chmod the directory read-only so openSync('a') hits EACCES on the lock - // target. The wrapper must throw rather than return null — otherwise sync - // would report "already running" for what's actually a disk problem. - const restrictedDir = join(testDir, 'no-write'); - mkdirSync(restrictedDir); - try { - chmodSync(restrictedDir, 0o500); // r-x, no write - const restrictedLock = join(restrictedDir, 'lock'); - expect(() => acquireFileLock(restrictedLock)).toThrow(); - } finally { - try { chmodSync(restrictedDir, 0o700); } catch {} - } + // A file in place of the parent directory fails on Windows and POSIX. + // chmod's Unix permission bits do not make a Windows directory read-only. + const invalidParent = join(testDir, 'not-a-directory'); + writeFileSync(invalidParent, 'fixture'); + expect(() => acquireFileLock(join(invalidParent, 'lock'))).toThrow(); }); }); diff --git a/test/fixtures/codex-cache-server.cjs b/test/fixtures/codex-cache-server.cjs new file mode 100644 index 00000000..162a1fea --- /dev/null +++ b/test/fixtures/codex-cache-server.cjs @@ -0,0 +1,20 @@ +// Synthetic app-server protocol only. No model or user files are accessed. +if (process.env.SYNTHETIC_EXPECT_ARGV0 && process.argv0 !== process.env.SYNTHETIC_EXPECT_ARGV0) throw Error('changed app-server argv0'); +const readline = require('readline'); +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', line => { + const m = JSON.parse(line); + const reply = result => console.log(JSON.stringify({ id: m.id, result })); + if (m.method === 'initialize') return reply({}); + if (m.method === 'initialized') return; + if (m.method === 'thread/fork') { + if (!m.params.ephemeral || !m.params.excludeTurns || m.params.sandbox !== 'read-only' || m.params.approvalPolicy !== 'never') throw Error('unsafe fork'); + return reply({ thread: { id: 'synthetic-fork' } }); + } + if (m.method === 'turn/start') { + if (m.params.threadId !== 'synthetic-fork') throw Error('wrong fork'); + reply({ turn: { id: 'synthetic-turn' } }); + console.log(JSON.stringify({ method: 'item/agentMessage/delta', params: { delta: 'synthetic summary' } })); + console.log(JSON.stringify({ method: 'turn/completed', params: { turn: { id: 'synthetic-turn', status: 'completed' } } })); + } +}); diff --git a/test/mcp-install-runner.test.ts b/test/mcp-install-runner.test.ts index 28cc4140..f4ef61f8 100644 --- a/test/mcp-install-runner.test.ts +++ b/test/mcp-install-runner.test.ts @@ -119,6 +119,28 @@ describe('install-runner — runNpmInstall termination handling (#161)', () => { expect(process.listenerCount('exit')).toBe(beforeExit); }); + it.runIf(process.platform === 'win32')('uses a hidden Windows shell child and releases its lock on explicit termination', async () => { + const child = makeFakeChild(); + const fakeSpawn = vi.fn(() => child); + const lock = acquireInstallLock(root); + expect(lock).not.toBeNull(); + + const install = runNpmInstall(root, { spawn: fakeSpawn, lockHandle: lock }); + expect(fakeSpawn).toHaveBeenCalledWith('npm.cmd', ['install', '--no-audit', '--no-fund'], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + shell: true, + detached: false, + }); + + install.terminate(); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(existsSync(join(root, '.episodic-memory-install.lock'))).toBe(false); + child.emit('exit', null); + await install.promise.catch(() => {}); + }); + it('resolves and removes handlers when the child exits 0 normally (no termination)', async () => { const beforeSigterm = process.listenerCount('SIGTERM'); @@ -163,7 +185,7 @@ describe('mcp-server-wrapper — real spawn, orphan prevention on SIGTERM (#161) try { rmSync(binDir, { recursive: true, force: true }); } catch {} }); - it('kills the stub npm install child when the wrapper is SIGTERM-ed mid-install', async () => { + it.skipIf(process.platform === 'win32')('kills the stub npm install child when the wrapper is SIGTERM-ed mid-install', async () => { // A stub "npm" that just stays alive and records its own pid, so we can // tell whether the wrapper's real child-tree kill reaches it. No network // call is made — this never touches the real npm registry. diff --git a/test/parser.test.ts b/test/parser.test.ts index 003d15b8..538cdb89 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -1,8 +1,25 @@ import { describe, it, expect } from 'vitest'; import { parseConversationFile } from '../src/parser.js'; import { getFixturePath, countLines } from './test-utils.js'; +import { writeFileSync, rmSync } from 'fs'; +import { randomUUID } from 'crypto'; describe('Parser - Real Conversation Data', () => { + it('keeps unknown project for a filename without a parent directory', async () => { + const filename = `parser-filename-only-${randomUUID()}.jsonl`; + writeFileSync(filename, + JSON.stringify({ type: 'user', message: { role: 'user', content: 'Synthetic question' }, timestamp: '2026-01-01T00:00:00Z' }) + '\n' + + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: 'Synthetic answer' }, timestamp: '2026-01-01T00:00:01Z' }) + '\n'); + try { + const result = await parseConversationFile(filename); + expect(result.project).toBe('unknown'); + expect(result.exchanges).toHaveLength(1); + expect(result.exchanges[0].project).toBe('unknown'); + } finally { + rmSync(filename, { force: true }); + } + }); + describe('Short conversation (3 lines)', () => { const fixturePath = getFixturePath('short-conversation.jsonl'); diff --git a/test/windows-hidden-spawn.cjs b/test/windows-hidden-spawn.cjs new file mode 100644 index 00000000..d4f243e9 --- /dev/null +++ b/test/windows-hidden-spawn.cjs @@ -0,0 +1,82 @@ +// Executes production modules with child_process and IO replaced at the boundary. +// Removing windowsHide from any of the six launch sites must fail this test. +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const {EventEmitter} = require('node:events'); +const {PassThrough} = require('node:stream'); +const readline = require('node:readline'); +const ts = require('typescript'); +const root = process.argv[2]; +const report = []; +const watchdog=setTimeout(()=>{console.error('test suite did not complete');process.exit(2)},15000); +const normalize = value => JSON.parse(JSON.stringify(value)); +function compile(path) { + const source = fs.readFileSync(root + '/' + path, 'utf8'); + const result = ts.transpileModule(source, {fileName:path, compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true},reportDiagnostics:true}); + const errors=(result.diagnostics||[]).filter(x=>x.category===ts.DiagnosticCategory.Error); + assert.equal(errors.length,0,'transpile errors'); + return result.outputText; +} +function context(path, spawn, extra={}) { + const exports = {}; + const env = {PATH:'fixture-path',EPISODIC_MEMORY_API_BASE_URL:'https://fixture.invalid',EPISODIC_MEMORY_API_TOKEN:'fixture-token',EPISODIC_MEMORY_API_TIMEOUT_MS:'9876'}; + const modules = { + 'child_process':{spawn}, 'readline':readline, 'fs':{}, + '@anthropic-ai/claude-agent-sdk':{query(){throw Error('SDK forbidden')}}, + './constants.js':{SUMMARIZER_CONTEXT_MARKER:'fixture-marker'}, './version.js':{VERSION:'1.6.0'}, + './codex-support.js':{parseCodexCliVersion:s=>s.includes('0.1.0')?'0.1.0':undefined,versionMeetsMinimum:()=>false,codexVersionRequirementMessage:()=> 'unsupported fixture version'}, + './codex-executable.js':{resolveCodexExecutable:()=>undefined}, + ...extra.modules + }; + const fakeProcess={env,argv:['fixture-node','fixture-sync','--background','--only','codex','--summary-limit','2'],execPath:'fixture-node',pid:123,exit(code){throw Object.assign(Error('test exit'),{exitCode:code})}}; + const sandbox={exports,require(name){if(!(name in modules)) throw Error('Unmocked import '+name); return modules[name]},process:fakeProcess,console:{log(){},error(){}},setTimeout,clearTimeout,Buffer}; + return {exports,env,run:()=>new vm.Script('(async function(){'+compile(path)+'\n})()', {filename:path}).runInNewContext(sandbox)}; +} +function child() { + const c=new EventEmitter(); c.stdout=new PassThrough(); c.stderr=new PassThrough(); c.stdin=new PassThrough(); c.killed=false; c.kill=()=>{c.killed=true}; c.unref=()=>{c.unreferenced=true}; return c; +} +async function summarizer(path, mode) { + const calls=[]; + const ctx=context(path,(command,args,options)=>{ + calls.push({command,args:normalize(args),options:normalize(options)}); + const c=child(); + queueMicrotask(()=>{if(mode==='version'){c.stdout.write('codex-cli 0.1.0');c.emit('exit',0)}else c.emit('error',Error('fixture stop'));}); + return c; + }); + await ctx.run(); + const command={command:'fixture-codex',args:['app-server','--listen','stdio://'],versionArgs:['--version'],sessionId:'synthetic-session',prompt:'synthetic prompt',skipVersionCheck:mode==='app'}; + await assert.rejects(ctx.exports.runCodexCommand(command),mode==='version'?/unsupported fixture version/:/fixture stop/); + assert.equal(calls.length,1); + const call=calls[0]; + assert.equal(call.command,'fixture-codex'); + assert.deepEqual(call.args,mode==='version'?['--version']:['app-server','--listen','stdio://']); + assert.deepEqual(call.options.stdio,mode==='version'?['ignore','pipe','pipe']:['pipe','pipe','pipe']); + assert.deepEqual(call.options.env,{PATH:'fixture-path',EPISODIC_MEMORY_API_BASE_URL:'https://fixture.invalid',EPISODIC_MEMORY_API_TOKEN:'fixture-token',EPISODIC_MEMORY_API_TIMEOUT_MS:'9876',EPISODIC_MEMORY_SUMMARIZER_GUARD:'1',ANTHROPIC_BASE_URL:'https://fixture.invalid',ANTHROPIC_AUTH_TOKEN:'fixture-token',API_TIMEOUT_MS:'9876'}); + assert.equal(call.options.windowsHide,true,'windowsHide must be true'); +} +async function sync(path) { + const calls=[]; let c; + const ctx=context(path,(command,args,options)=>{calls.push({command,args:normalize(args),options:normalize(options)});c=child();return c},{modules:{ + './sync.js':{},'./paths.js':{},'./summarizer.js':{shouldSkipReentrantSync:()=>false},'./db.js':{},'./embeddings.js':{},'./embedding-migration.js':{},'./opencode-sync.js':{},'./file-lock.js':{}, + './logging.js':{getSyncLogPath:()=> 'fixture.log',formatLogLine:()=> 'fixture line'}, + fs:{openSync:(path,mode)=>{assert.equal(path,'fixture.log');assert.equal(mode,'a');return 42},writeSync:(fd)=>assert.equal(fd,42)} + }}); + await assert.rejects(ctx.run(),e=>e.exitCode===0); + assert.equal(calls.length,1); assert.equal(calls[0].command,'fixture-node'); + assert.deepEqual(calls[0].args,['fixture-sync','--only','codex','--summary-limit','2']); + assert.deepEqual(calls[0].options.stdio,['ignore',42,42]); + assert.equal(calls[0].options.detached,true); assert.equal('env' in calls[0].options,false); assert.equal(c.unreferenced,true); + assert.equal(calls[0].options.windowsHide,true,'windowsHide must be true'); +} +(async()=>{ + for(const path of ['src/summarizer.ts','dist/summarizer.js']) for(const mode of ['version','app']) { + try{await summarizer(path,mode);report.push({path,mode,pass:true})}catch(e){report.push({path,mode,pass:false,error:e.message})} + } + for(const path of ['src/sync-cli.ts','dist/sync-cli.js']) { + try{await sync(path);report.push({path,mode:'background',pass:true})}catch(e){report.push({path,mode:'background',pass:false,error:e.message})} + } + clearTimeout(watchdog); + console.log(JSON.stringify({root,tests:report,passed:report.filter(t=>t.pass).length,failed:report.filter(t=>!t.pass).length,realChildrenLaunched:0},null,2)); + process.exitCode=report.some(t=>!t.pass)?1:0; +})().catch(e=>{console.error(e);process.exitCode=2}); diff --git a/test/windows-hidden-spawn.test.ts b/test/windows-hidden-spawn.test.ts new file mode 100644 index 00000000..f9131f88 --- /dev/null +++ b/test/windows-hidden-spawn.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +describe('Windows child launch options', () => { + it('keeps Codex version, app-server, and background sync children hidden in source and build output', () => { + const result = spawnSync(process.execPath, [join(root, 'test/windows-hidden-spawn.cjs'), root], { + cwd: root, encoding: 'utf8', timeout: 20_000, windowsHide: true, + }); + expect(result.status, result.stderr).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.failed).toBe(0); + expect(report.passed).toBe(6); + expect(report.realChildrenLaunched).toBe(0); + }); + + it.each([ + ['command', 'cli/episodic-memory.js'], + ['mcp', 'cli/mcp-server-wrapper.js'], + ])('keeps the %s CLI child hidden while preserving inherited IO', (mode, relativePath) => { + const target = join(root, relativePath); + const hostileRoot = mkdtempSync(join(tmpdir(), 'episodic-hostile-parent-')); + const sentinel = join(hostileRoot, 'sentinel'); + writeFileSync(sentinel, 'unchanged'); + try { + const result = spawnSync(process.execPath, [join(root, 'test/cli-hidden-spawn-fixture.cjs'), target, mode], { + cwd: root, encoding: 'utf8', timeout: 10_000, windowsHide: true, + env: { ...process.env, CLAUDE_PLUGIN_ROOT: hostileRoot }, + }); + expect(result.status, result.stderr).toBe(0); + const calls = result.stdout.split(/\r?\n/).filter(line => line.startsWith('SPAWN_OPTIONS=')); + expect(calls).toHaveLength(1); + const call = JSON.parse(calls[0].slice('SPAWN_OPTIONS='.length)); + expect(call.command).toBe(process.execPath); + expect(call.options.windowsHide).toBe(true); + expect(call.options.stdio).toBe('inherit'); + if (mode === 'mcp') expect(call.options.shell).toBe(false); + expect(existsSync(join(hostileRoot, 'node_modules'))).toBe(false); + expect(existsSync(sentinel)).toBe(true); + } finally { + rmSync(hostileRoot, { recursive: true, force: true }); + } + }); +});