Compare commits

...

1 Commits

Author SHA1 Message Date
David Wiesen
f337cb9b4b Fix SDK app-server helper PATH resolution 2026-04-25 20:42:55 -07:00
4 changed files with 139 additions and 0 deletions

View File

@@ -120,6 +120,26 @@ def _resolve_codex_bin(config: "AppServerConfig") -> Path:
return resolve_codex_bin(config, _default_codex_bin_resolver_ops())
def _prepend_codex_runtime_dirs(env: dict[str, str], codex_bin: Path) -> None:
search_dirs = [str(codex_bin.parent)]
resources_dir = codex_bin.parent / "codex-resources"
if resources_dir.is_dir():
search_dirs.append(str(resources_dir))
existing_entries = [entry for entry in env.get("PATH", "").split(os.pathsep) if entry]
merged_entries: list[str] = []
seen: set[str] = set()
for entry in [*search_dirs, *existing_entries]:
key = entry.lower() if os.name == "nt" else entry
if key in seen:
continue
seen.add(key)
merged_entries.append(entry)
env["PATH"] = os.pathsep.join(merged_entries)
@dataclass(slots=True)
class AppServerConfig:
codex_bin: str | None = None
@@ -174,6 +194,8 @@ class AppServerClient:
env = os.environ.copy()
if self.config.env:
env.update(self.config.env)
if self.config.launch_args_override is None:
_prepend_codex_runtime_dirs(env, codex_bin)
self._proc = subprocess.Popen(
args,

View File

@@ -7,6 +7,7 @@ import json
import sys
import tomllib
import urllib.error
import io
from pathlib import Path
import pytest
@@ -512,3 +513,36 @@ def test_broken_runtime_package_does_not_fall_back() -> None:
client_module.resolve_codex_bin(client_module.AppServerConfig(), ops)
assert str(exc_info.value) == ("missing packaged binary")
def test_start_prepends_codex_runtime_dirs_to_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from codex_app_server import client as client_module
codex_bin = tmp_path / ("codex.exe" if client_module.os.name == "nt" else "codex")
codex_bin.write_text("")
resources_dir = tmp_path / "codex-resources"
resources_dir.mkdir()
captured: dict[str, object] = {}
class FakePopen:
def __init__(self, args, **kwargs): # noqa: ANN001
captured["args"] = args
captured["env"] = kwargs["env"]
self.stdin = io.StringIO()
self.stdout = io.StringIO()
self.stderr = io.StringIO()
monkeypatch.setattr(client_module, "_resolve_codex_bin", lambda _config: codex_bin)
monkeypatch.setattr(client_module.subprocess, "Popen", FakePopen)
client = client_module.AppServerClient(
client_module.AppServerConfig(env={"PATH": f"/usr/bin{client_module.os.pathsep}/bin"})
)
client.start()
env = captured["env"]
assert isinstance(env, dict)
assert env["PATH"] == client_module.os.pathsep.join(
[str(tmp_path), str(resources_dir), f"/usr/bin{client_module.os.pathsep}/bin"]
)

View File

@@ -1,4 +1,5 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { createRequire } from "node:module";
@@ -160,6 +161,7 @@ export class CodexExec {
if (args.apiKey) {
env.CODEX_API_KEY = args.apiKey;
}
applyBundledCodexPath(env, this.executablePath);
const child = spawn(this.executablePath, commandArgs, {
env,
@@ -314,6 +316,59 @@ function isPlainObject(value: unknown): value is CodexConfigObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function applyBundledCodexPath(env: Record<string, string>, executablePath: string): void {
const searchDirs = bundledCodexSearchDirs(executablePath);
if (searchDirs.length === 0) {
return;
}
const existingEntries = (env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean);
const seen = new Set<string>();
const mergedEntries: string[] = [];
for (const entry of [...searchDirs, ...existingEntries]) {
const key = normalizePathForDedup(entry);
if (seen.has(key)) {
continue;
}
seen.add(key);
mergedEntries.push(entry);
}
env.PATH = mergedEntries.join(path.delimiter);
}
function bundledCodexSearchDirs(executablePath: string): string[] {
const codexDir = executableDirectory(executablePath);
if (!codexDir) {
return [];
}
const searchDirs = [codexDir];
const resourcesDir = path.join(codexDir, "codex-resources");
if (existsSync(resourcesDir)) {
searchDirs.push(resourcesDir);
}
return searchDirs;
}
function executableDirectory(executablePath: string): string | null {
if (!path.isAbsolute(executablePath) && !containsPathSeparator(executablePath)) {
return null;
}
return path.dirname(executablePath);
}
function containsPathSeparator(value: string): boolean {
return value.includes(path.sep) || value.includes(path.posix.sep) || value.includes(path.win32.sep);
}
function normalizePathForDedup(entry: string): string {
return process.platform === "win32" ? entry.toLowerCase() : entry;
}
function findCodexPath() {
const { platform, arch } = process;

View File

@@ -1,5 +1,6 @@
import * as child_process from "node:child_process";
import { EventEmitter } from "node:events";
import path from "node:path";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "@jest/globals";
@@ -142,4 +143,31 @@ describe("CodexExec", () => {
delete process.env.CODEX_ENV_SHOULD_NOT_LEAK;
}
});
it("prepends the Codex binary directory and resources directory to PATH", async () => {
const { CodexExec } = await import("../src/exec");
spawnMock.mockClear();
const child = new FakeChildProcess();
spawnMock.mockReturnValue(child as unknown as child_process.ChildProcess);
setImmediate(() => {
child.stdout.end();
child.stderr.end();
child.emit("exit", 0, null);
});
const exec = new CodexExec("/tmp/codex-release/codex", {
PATH: "/usr/local/bin",
});
for await (const _ of exec.run({ input: "path test" })) {
// no-op
}
const spawnOptions = spawnMock.mock.calls[0]?.[2] as child_process.SpawnOptions | undefined;
const spawnEnv = spawnOptions?.env as Record<string, string> | undefined;
expect(spawnEnv?.PATH).toBe(
["/tmp/codex-release", "/usr/local/bin"].join(path.delimiter),
);
});
});