mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Fix Codex wrapper launch paths (#1395)
This commit is contained in:
parent
6c3fd86642
commit
28d3e5faf5
8 changed files with 410 additions and 35 deletions
|
|
@ -6,6 +6,7 @@ export {
|
|||
inspectAgentExecutableResolution,
|
||||
resolveAgentExecutable,
|
||||
} from './runtimes/executables.js';
|
||||
export { applyAgentLaunchEnv, resolveAgentLaunch } from './runtimes/launch.js';
|
||||
export { resolveAgentBin } from './runtimes/resolution.js';
|
||||
export { spawnEnvForAgent } from './runtimes/env.js';
|
||||
export { buildLiveArtifactsMcpServersForAgent } from './runtimes/mcp.js';
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import { promises as fsp } from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
applyAgentLaunchEnv,
|
||||
getAgentDef,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentBin,
|
||||
resolveAgentLaunch,
|
||||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
|
|
@ -1003,12 +1003,9 @@ async function testAgentConnectionInternal(
|
|||
validateAgentCliEnv(input.agentCliEnv),
|
||||
input.agentId,
|
||||
);
|
||||
const executableResolution = inspectAgentExecutableResolution(
|
||||
def,
|
||||
configuredAgentEnv,
|
||||
);
|
||||
const resolvedBin = resolveAgentBin(input.agentId, configuredAgentEnv);
|
||||
if (!resolvedBin) {
|
||||
const executableResolution = resolveAgentLaunch(def, configuredAgentEnv);
|
||||
const resolvedBin = executableResolution.selectedPath;
|
||||
if (!resolvedBin || !executableResolution.launchPath) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'agent_not_installed',
|
||||
|
|
@ -1128,16 +1125,16 @@ async function testAgentConnectionInternal(
|
|||
}
|
||||
const stdinMode =
|
||||
def.promptViaStdin || def.streamFormat === 'acp-json-rpc' ? 'pipe' : 'ignore';
|
||||
const env = spawnEnvForAgent(
|
||||
const env = applyAgentLaunchEnv(spawnEnvForAgent(
|
||||
input.agentId,
|
||||
{
|
||||
...process.env,
|
||||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
);
|
||||
), executableResolution);
|
||||
const invocation = createCommandInvocation({
|
||||
command: resolvedBin,
|
||||
command: executableResolution.launchPath,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
|
|
@ -1176,11 +1173,11 @@ async function testAgentConnectionInternal(
|
|||
const latencyMs = Date.now() - start;
|
||||
const detail = redactSecrets(winner.error.message);
|
||||
const guidance = redactSecrets(
|
||||
codexExecutableGuidance(
|
||||
`${codexExecutableGuidance(
|
||||
input.agentId,
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`,
|
||||
);
|
||||
const errnoCode = (winner.error as NodeJS.ErrnoException).code;
|
||||
const isMissing = errnoCode === 'ENOENT';
|
||||
|
|
@ -1263,11 +1260,11 @@ async function testAgentConnectionInternal(
|
|||
.join(' · '),
|
||||
);
|
||||
const guidance = redactSecrets(
|
||||
codexExecutableGuidance(
|
||||
`${codexExecutableGuidance(
|
||||
input.agentId,
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`,
|
||||
);
|
||||
const label = buffered ? 'exit_failed' : 'no_text';
|
||||
console.warn(
|
||||
|
|
@ -1394,11 +1391,15 @@ export async function testAgentConnection(
|
|||
const configuredAgentEnv = agentCliEnvForAgent(validatedPrefs, input.agentId);
|
||||
const def = getAgentDef(input.agentId);
|
||||
const executableResolution = def
|
||||
? inspectAgentExecutableResolution(def, configuredAgentEnv)
|
||||
? resolveAgentLaunch(def, configuredAgentEnv)
|
||||
: {
|
||||
configuredOverridePath: null,
|
||||
pathResolvedPath: null,
|
||||
selectedPath: null,
|
||||
launchPath: null,
|
||||
launchKind: 'selected' as const,
|
||||
childPathPrepend: [],
|
||||
diagnostic: null,
|
||||
};
|
||||
if (
|
||||
input.agentId === 'codex' &&
|
||||
|
|
@ -1409,7 +1410,7 @@ export async function testAgentConnection(
|
|||
return {
|
||||
...primaryResult,
|
||||
configuredExecutablePath: executableResolution.configuredOverridePath,
|
||||
usedExecutablePath: executableResolution.configuredOverridePath,
|
||||
usedExecutablePath: executableResolution.launchPath ?? executableResolution.configuredOverridePath,
|
||||
usedExecutableSource: 'configured',
|
||||
...(executableResolution.pathResolvedPath
|
||||
? { detectedExecutablePath: executableResolution.pathResolvedPath }
|
||||
|
|
@ -1426,7 +1427,7 @@ export async function testAgentConnection(
|
|||
...primaryResult,
|
||||
configuredExecutablePath: configuredCodexBin,
|
||||
detectedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
|
||||
usedExecutableSource: 'fallback_invalid',
|
||||
detail: redactSecrets(
|
||||
codexInvalidConfiguredPathFallbackDetail(
|
||||
|
|
@ -1460,7 +1461,7 @@ export async function testAgentConnection(
|
|||
...fallbackResult,
|
||||
configuredExecutablePath: executableResolution.configuredOverridePath,
|
||||
detectedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
|
||||
usedExecutableSource: 'fallback_failed',
|
||||
detail: redactSecrets(
|
||||
codexExecutableFallbackSuccessDetail(
|
||||
|
|
|
|||
164
apps/daemon/src/runtimes/launch.ts
Normal file
164
apps/daemon/src/runtimes/launch.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { accessSync, constants, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import path, { delimiter } from 'node:path';
|
||||
import { inspectAgentExecutableResolution } from './executables.js';
|
||||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
||||
export type AgentLaunchKind = 'selected' | 'codex-native';
|
||||
|
||||
export type AgentLaunchResolution = ReturnType<typeof inspectAgentExecutableResolution> & {
|
||||
launchPath: string | null;
|
||||
launchKind: AgentLaunchKind;
|
||||
childPathPrepend: string[];
|
||||
diagnostic: string | null;
|
||||
};
|
||||
|
||||
export function resolveAgentLaunch(
|
||||
def: RuntimeAgentDef,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): AgentLaunchResolution {
|
||||
const resolution = inspectAgentExecutableResolution(def, configuredEnv);
|
||||
if (!resolution.selectedPath) {
|
||||
return { ...resolution, launchPath: null, launchKind: 'selected', childPathPrepend: [], diagnostic: null };
|
||||
}
|
||||
const childPathPrepend = path.isAbsolute(resolution.selectedPath)
|
||||
? [path.dirname(resolution.selectedPath)]
|
||||
: [];
|
||||
if (def.id !== 'codex') {
|
||||
return { ...resolution, launchPath: resolution.selectedPath, launchKind: 'selected', childPathPrepend, diagnostic: null };
|
||||
}
|
||||
const native = tryResolveCodexNativeBinary(resolution.selectedPath);
|
||||
return {
|
||||
...resolution,
|
||||
launchPath: native.path ?? resolution.selectedPath,
|
||||
launchKind: native.path ? 'codex-native' : 'selected',
|
||||
childPathPrepend: [...childPathPrepend, ...native.childPathPrepend],
|
||||
diagnostic: native.diagnostic,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAgentLaunchEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
launch: Pick<AgentLaunchResolution, 'childPathPrepend'>,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (launch.childPathPrepend.length === 0) return env;
|
||||
const existing = typeof env.PATH === 'string' ? env.PATH : '';
|
||||
const PATH = [...launch.childPathPrepend, ...existing.split(delimiter)]
|
||||
.filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index)
|
||||
.join(delimiter);
|
||||
return { ...env, PATH };
|
||||
}
|
||||
|
||||
function tryResolveCodexNativeBinary(wrapperPath: string): {
|
||||
path: string | null;
|
||||
childPathPrepend: string[];
|
||||
diagnostic: string | null;
|
||||
} {
|
||||
const packageSuffix = codexNativePackageSuffix();
|
||||
const targetTriple = codexNativeTargetTriple();
|
||||
for (const root of codexSearchRoots(wrapperPath)) {
|
||||
for (const candidate of codexNativeCandidates(root, packageSuffix, targetTriple)) {
|
||||
if (isExecutableFile(candidate.path)) {
|
||||
return { path: candidate.path, childPathPrepend: existingDirectories(candidate.childPathPrepend), diagnostic: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!looksLikeCodexNodeWrapper(wrapperPath)) return { path: null, childPathPrepend: [], diagnostic: null };
|
||||
return {
|
||||
path: null,
|
||||
childPathPrepend: [],
|
||||
diagnostic: `Codex native binary was not found for ${packageSuffix}/${targetTriple}; falling back to wrapper ${wrapperPath}. Set CODEX_BIN to a native Codex binary if this wrapper cannot launch from a GUI environment.`,
|
||||
};
|
||||
}
|
||||
|
||||
function codexSearchRoots(wrapperPath: string): string[] {
|
||||
const roots = new Set<string>();
|
||||
for (const seed of [wrapperPath, safeRealpath(wrapperPath)]) {
|
||||
if (!seed) continue;
|
||||
let current = path.dirname(seed);
|
||||
while (current !== path.dirname(current)) {
|
||||
roots.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function codexNativeCandidates(
|
||||
root: string,
|
||||
packageSuffix: string,
|
||||
targetTriple: string,
|
||||
): Array<{ path: string; childPathPrepend: string[] }> {
|
||||
const scoped = path.join(root, 'node_modules', '@openai');
|
||||
const packageDirs = [path.join(scoped, `codex-${packageSuffix}`)];
|
||||
try {
|
||||
for (const entry of readdirSync(scoped, { encoding: 'utf8', withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('codex-')) packageDirs.push(path.join(scoped, entry.name));
|
||||
}
|
||||
} catch {
|
||||
// Optional package layouts vary by npm version; absence uses wrapper fallback.
|
||||
}
|
||||
return [...new Set(packageDirs)].flatMap((dir) => {
|
||||
const vendorPathDir = path.join(dir, 'vendor', targetTriple, 'path');
|
||||
const childPathPrepend = [vendorPathDir];
|
||||
return [
|
||||
{ path: path.join(dir, 'vendor', targetTriple, 'codex', 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'vendor', targetTriple, 'codex', 'codex.exe'), childPathPrepend },
|
||||
{ path: path.join(dir, 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'bin', 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'vendor', 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'codex.exe'), childPathPrepend },
|
||||
{ path: path.join(dir, 'bin', 'codex.exe'), childPathPrepend },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function codexNativePackageSuffix(): string {
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
function codexNativeTargetTriple(): string {
|
||||
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
|
||||
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
|
||||
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
|
||||
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
|
||||
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
|
||||
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
function looksLikeCodexNodeWrapper(filePath: string): boolean {
|
||||
try {
|
||||
const body = readFileSync(filePath, { encoding: 'utf8' }).slice(0, 64_000);
|
||||
return /node|@openai\/codex|codex-/i.test(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeRealpath(filePath: string): string | null {
|
||||
try {
|
||||
return realpathSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function existingDirectories(dirs: string[]): string[] {
|
||||
return dirs.filter((dir) => {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) return false;
|
||||
if (process.platform !== 'win32') accessSync(filePath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,8 @@ import {
|
|||
detectAgents,
|
||||
getAgentDef,
|
||||
isKnownModel,
|
||||
resolveAgentBin,
|
||||
applyAgentLaunchEnv,
|
||||
resolveAgentLaunch,
|
||||
sanitizeCustomModel,
|
||||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
|
|
@ -3575,7 +3576,8 @@ export async function startServer({
|
|||
configuredAgentEnv = {};
|
||||
}
|
||||
|
||||
const resolvedBin = resolveAgentBin(agentId, configuredAgentEnv);
|
||||
const agentLaunch = resolveAgentLaunch(def, configuredAgentEnv);
|
||||
const resolvedBin = agentLaunch.selectedPath;
|
||||
|
||||
const args = def.buildArgs(
|
||||
composed,
|
||||
|
|
@ -3597,7 +3599,7 @@ export async function startServer({
|
|||
// doesn't have to special-case it.
|
||||
const cmdShimBudgetError = checkWindowsCmdShimCommandLineBudget(
|
||||
def,
|
||||
resolvedBin,
|
||||
agentLaunch.launchPath ?? resolvedBin,
|
||||
args,
|
||||
);
|
||||
if (cmdShimBudgetError) {
|
||||
|
|
@ -3624,7 +3626,7 @@ export async function startServer({
|
|||
// users hit a generic `spawn ENAMETOOLONG`.
|
||||
const directExeBudgetError = checkWindowsDirectExeCommandLineBudget(
|
||||
def,
|
||||
resolvedBin,
|
||||
agentLaunch.launchPath ?? resolvedBin,
|
||||
args,
|
||||
);
|
||||
if (directExeBudgetError) {
|
||||
|
|
@ -3716,7 +3718,7 @@ export async function startServer({
|
|||
// pointing at /api/agents instead of silently falling back to
|
||||
// spawn(def.bin) — that fallback re-introduces the exact ENOENT symptom
|
||||
// from issue #10.
|
||||
if (!resolvedBin) {
|
||||
if (!resolvedBin || !agentLaunch.launchPath) {
|
||||
revokeToolToken('child_exit');
|
||||
unregisterChatAgentEventSink();
|
||||
send('error', createSseErrorPayload(
|
||||
|
|
@ -3772,7 +3774,7 @@ export async function startServer({
|
|||
def.promptViaStdin || def.streamFormat === 'acp-json-rpc'
|
||||
? 'pipe'
|
||||
: 'ignore';
|
||||
const env = {
|
||||
const env = applyAgentLaunchEnv({
|
||||
...spawnEnvForAgent(
|
||||
def.id,
|
||||
{
|
||||
|
|
@ -3782,10 +3784,10 @@ export async function startServer({
|
|||
configuredAgentEnv,
|
||||
),
|
||||
...odMediaEnv,
|
||||
};
|
||||
}, agentLaunch);
|
||||
spawnedAgentEnv = env;
|
||||
const invocation = createCommandInvocation({
|
||||
command: resolvedBin,
|
||||
command: agentLaunch.launchPath,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import {
|
||||
AGENT_DEFS,
|
||||
applyAgentLaunchEnv,
|
||||
buildLiveArtifactsMcpServersForAgent,
|
||||
checkPromptArgvBudget,
|
||||
checkWindowsCmdShimCommandLineBudget,
|
||||
checkWindowsDirectExeCommandLineBudget,
|
||||
detectAgents,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentLaunch,
|
||||
resolveAgentExecutable,
|
||||
spawnEnvForAgent,
|
||||
} from '../../../src/agents.js';
|
||||
|
|
@ -25,6 +27,7 @@ import type { RuntimeAgentDef } from '../../../src/runtimes/types.js';
|
|||
export {
|
||||
assert,
|
||||
AGENT_DEFS,
|
||||
applyAgentLaunchEnv,
|
||||
buildLiveArtifactsMcpServersForAgent,
|
||||
checkPromptArgvBudget,
|
||||
checkWindowsCmdShimCommandLineBudget,
|
||||
|
|
@ -36,6 +39,7 @@ export {
|
|||
mkdirSync,
|
||||
mkdtempSync,
|
||||
resolveAgentExecutable,
|
||||
resolveAgentLaunch,
|
||||
rmSync,
|
||||
spawnEnvForAgent,
|
||||
tmpdir,
|
||||
|
|
|
|||
159
apps/daemon/tests/runtimes/launch.test.ts
Normal file
159
apps/daemon/tests/runtimes/launch.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { delimiter, join } from 'node:path';
|
||||
import { realpathSync, symlinkSync } from 'node:fs';
|
||||
import { test } from 'vitest';
|
||||
import {
|
||||
applyAgentLaunchEnv,
|
||||
assert,
|
||||
chmodSync,
|
||||
codex,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
resolveAgentLaunch,
|
||||
rmSync,
|
||||
tmpdir,
|
||||
withEnvSnapshot,
|
||||
writeFileSync,
|
||||
} from './helpers/test-helpers.js';
|
||||
|
||||
const fsTest = process.platform === 'win32' ? test.skip : test;
|
||||
|
||||
test('applyAgentLaunchEnv prepends the selected executable dirname and dedupes PATH', () => {
|
||||
const launch = {
|
||||
childPathPrepend: ['/opt/tools/bin', '/opt/tools/bin'],
|
||||
};
|
||||
|
||||
const env = applyAgentLaunchEnv(
|
||||
{ PATH: ['/usr/bin', '/opt/tools/bin', '/bin', '/usr/bin'].join(delimiter) },
|
||||
launch,
|
||||
);
|
||||
|
||||
assert.equal(env.PATH, ['/opt/tools/bin', '/usr/bin', '/bin'].join(delimiter));
|
||||
});
|
||||
|
||||
fsTest('resolveAgentLaunch selects nvm-installed codex under a minimal PATH and prepends its dirname', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'od-launch-nvm-'));
|
||||
try {
|
||||
return withEnvSnapshot(['HOME', 'PATH', 'OD_AGENT_HOME'], () => {
|
||||
const binDir = join(home, '.nvm', 'versions', 'node', '24.11.0', 'bin');
|
||||
const codexBin = join(binDir, 'codex');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(codexBin, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(codexBin, 0o755);
|
||||
process.env.HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
process.env.OD_AGENT_HOME = home;
|
||||
|
||||
const launch = resolveAgentLaunch(codex);
|
||||
|
||||
assert.equal(launch.selectedPath, codexBin);
|
||||
assert.equal(launch.launchPath, codexBin);
|
||||
assert.deepEqual(launch.childPathPrepend, [binDir]);
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
fsTest('resolveAgentLaunch resolves a Codex npm wrapper to the native packaged binary', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-wrapper-'));
|
||||
try {
|
||||
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
|
||||
const wrapperPkgDir = join(root, 'node_modules', '@openai', 'codex');
|
||||
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
|
||||
const wrapperLinkDir = join(root, 'node_modules', '.bin');
|
||||
const wrapperLinkPath = join(wrapperLinkDir, 'codex');
|
||||
const nativePkgDir = join(wrapperPkgDir, 'node_modules', '@openai', `codex-${process.platform}-${process.arch}`);
|
||||
const nativePathDir = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'path');
|
||||
const nativeBin = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex', 'codex');
|
||||
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
|
||||
mkdirSync(wrapperLinkDir, { recursive: true });
|
||||
mkdirSync(join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex'), { recursive: true });
|
||||
mkdirSync(nativePathDir, { recursive: true });
|
||||
writeFileSync(wrapperRealPath, '#!/usr/bin/env node\nrequire("@openai/codex");\n');
|
||||
writeFileSync(nativeBin, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(wrapperRealPath, 0o755);
|
||||
chmodSync(nativeBin, 0o755);
|
||||
symlinkSync(wrapperRealPath, wrapperLinkPath);
|
||||
process.env.PATH = wrapperLinkDir;
|
||||
process.env.OD_AGENT_HOME = root;
|
||||
|
||||
const launch = resolveAgentLaunch(codex);
|
||||
|
||||
assert.equal(launch.selectedPath, wrapperLinkPath);
|
||||
assert.equal(launch.launchPath, realpathSync(nativeBin));
|
||||
assert.equal(launch.launchKind, 'codex-native');
|
||||
assert.deepEqual(launch.childPathPrepend, [wrapperLinkDir, realpathSync(nativePathDir)]);
|
||||
assert.equal(launch.diagnostic, null);
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function codexNativeTargetTriple(): string {
|
||||
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
|
||||
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
|
||||
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
|
||||
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
|
||||
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
|
||||
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
fsTest('resolveAgentLaunch preserves a direct native CODEX_BIN override as the selected launch path', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-direct-native-'));
|
||||
try {
|
||||
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
|
||||
const nativeBin = join(root, 'codex-native');
|
||||
const pathCodex = join(root, 'codex');
|
||||
writeFileSync(nativeBin, '#!/bin/sh\nexit 0\n');
|
||||
writeFileSync(pathCodex, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(nativeBin, 0o755);
|
||||
chmodSync(pathCodex, 0o755);
|
||||
process.env.PATH = root;
|
||||
process.env.OD_AGENT_HOME = root;
|
||||
|
||||
const launch = resolveAgentLaunch(codex, { CODEX_BIN: nativeBin });
|
||||
|
||||
assert.equal(launch.configuredOverridePath, nativeBin);
|
||||
assert.equal(launch.pathResolvedPath, pathCodex);
|
||||
assert.equal(launch.selectedPath, nativeBin);
|
||||
assert.equal(launch.launchPath, nativeBin);
|
||||
assert.equal(launch.launchKind, 'selected');
|
||||
assert.deepEqual(launch.childPathPrepend, [root]);
|
||||
assert.equal(launch.diagnostic, null);
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
fsTest('resolveAgentLaunch falls back to the Codex wrapper when the native package is missing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-fallback-'));
|
||||
try {
|
||||
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
|
||||
const wrapperPkgDir = join(root, 'node_modules', '@openai', 'codex');
|
||||
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
|
||||
const wrapperLinkDir = join(root, 'node_modules', '.bin');
|
||||
const wrapperLinkPath = join(wrapperLinkDir, 'codex');
|
||||
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
|
||||
mkdirSync(wrapperLinkDir, { recursive: true });
|
||||
writeFileSync(wrapperRealPath, '#!/usr/bin/env node\nrequire("@openai/codex");\n');
|
||||
chmodSync(wrapperRealPath, 0o755);
|
||||
symlinkSync(wrapperRealPath, wrapperLinkPath);
|
||||
process.env.PATH = wrapperLinkDir;
|
||||
process.env.OD_AGENT_HOME = root;
|
||||
|
||||
const launch = resolveAgentLaunch(codex);
|
||||
|
||||
assert.equal(launch.selectedPath, wrapperLinkPath);
|
||||
assert.equal(launch.launchPath, wrapperLinkPath);
|
||||
assert.equal(launch.launchKind, 'selected');
|
||||
assert.deepEqual(launch.childPathPrepend, [wrapperLinkDir]);
|
||||
assert.match(launch.diagnostic ?? '', /native binary/i);
|
||||
assert.match(launch.diagnostic ?? '', /CODEX_BIN/);
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -544,10 +544,38 @@ function existingChildBinDirs(root: string, segments: string[]): string[] {
|
|||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
for (const entry of sortVersionedDirEntries(entries)) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = join(root, entry.name, ...segments);
|
||||
if (existsSync(candidate)) out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SemverParts = [major: number, minor: number, patch: number];
|
||||
|
||||
function sortVersionedDirEntries(entries: import("node:fs").Dirent<string>[]): import("node:fs").Dirent<string>[] {
|
||||
return [...entries].sort((left, right) => compareVersionLikeDirNames(left.name, right.name));
|
||||
}
|
||||
|
||||
function compareVersionLikeDirNames(left: string, right: string): number {
|
||||
const leftSemver = parseVersionLikeDirName(left);
|
||||
const rightSemver = parseVersionLikeDirName(right);
|
||||
if (leftSemver && rightSemver) {
|
||||
for (let index = 0; index < leftSemver.length; index += 1) {
|
||||
const difference = rightSemver[index] - leftSemver[index];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
} else if (leftSemver) {
|
||||
return -1;
|
||||
} else if (rightSemver) {
|
||||
return 1;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function parseVersionLikeDirName(name: string): SemverParts | null {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(name);
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -502,26 +502,42 @@ describe("wellKnownUserToolchainBins", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("expands per-version Node toolchains for mise / nvm / fnm", () => {
|
||||
it("surfaces GUI-safe PATH additions and sorts versioned Node bins by highest semver first", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-versioned-"));
|
||||
try {
|
||||
const miseBin = join(home, ".local", "share", "mise", "installs", "node", "24.14.1", "bin");
|
||||
const nvmBin = join(home, ".nvm", "versions", "node", "v22.10.0", "bin");
|
||||
const newestNvmBin = join(home, ".nvm", "versions", "node", "v24.1.0", "bin");
|
||||
const olderNvmBin = join(home, ".nvm", "versions", "node", "v22.10.0", "bin");
|
||||
const fnmBin = join(home, ".local", "share", "fnm", "node-versions", "v20.11.1", "installation", "bin");
|
||||
mkdirSync(miseBin, { recursive: true });
|
||||
mkdirSync(nvmBin, { recursive: true });
|
||||
mkdirSync(newestNvmBin, { recursive: true });
|
||||
mkdirSync(olderNvmBin, { recursive: true });
|
||||
mkdirSync(fnmBin, { recursive: true });
|
||||
writeFileSync(join(miseBin, "marker"), "");
|
||||
writeFileSync(join(nvmBin, "marker"), "");
|
||||
writeFileSync(join(newestNvmBin, "marker"), "");
|
||||
writeFileSync(join(olderNvmBin, "marker"), "");
|
||||
writeFileSync(join(fnmBin, "marker"), "");
|
||||
chmodSync(join(miseBin, "marker"), 0o644);
|
||||
chmodSync(join(nvmBin, "marker"), 0o644);
|
||||
chmodSync(join(newestNvmBin, "marker"), 0o644);
|
||||
chmodSync(join(olderNvmBin, "marker"), 0o644);
|
||||
chmodSync(join(fnmBin, "marker"), 0o644);
|
||||
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
const dirs = wellKnownUserToolchainBins({
|
||||
home,
|
||||
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" },
|
||||
includeSystemBins: true,
|
||||
});
|
||||
const newestNvmIdx = dirs.indexOf(newestNvmBin);
|
||||
const olderNvmIdx = dirs.indexOf(olderNvmBin);
|
||||
|
||||
expect(dirs).toContain("/opt/homebrew/bin");
|
||||
expect(dirs).toContain("/usr/local/bin");
|
||||
expect(dirs).toContain(miseBin);
|
||||
expect(dirs).toContain(nvmBin);
|
||||
expect(dirs).toContain(newestNvmBin);
|
||||
expect(dirs).toContain(olderNvmBin);
|
||||
expect(dirs).toContain(fnmBin);
|
||||
expect(newestNvmIdx).toBeGreaterThan(-1);
|
||||
expect(olderNvmIdx).toBeGreaterThan(newestNvmIdx);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue