mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
sdk: launch packaged Codex runtimes
## Why The Python and TypeScript SDKs launch the native Codex runtime directly, so they need to understand the same package layout that the release artifacts now use. Without this, SDK runtime wheels would keep carrying only a loose `bin/codex` plus ad hoc helper binaries, the TypeScript SDK would keep resolving the legacy optional-dependency layout, and bundled `codex-path` tools such as `rg` would not be prepended when the SDK starts Codex. ## What Changed - Stage `openai-codex-cli-bin` from a Codex package directory, including `codex-package.json`, `bin/`, `codex-resources/`, and `codex-path/`. - Preserve the legacy loose-binary staging path for compatibility, but shape its output like a package directory and put helper resources under `codex-resources/`. - Expose Python runtime helpers for the bundled package directory and `codex-path`, and prepend that path when `openai_codex` launches the installed runtime. - Teach the TypeScript SDK to resolve package-layout optional dependencies while keeping the legacy layout fallback. - Update the Rust release workflows to build Python runtime wheels from the generated Codex package directories instead of loose binaries. ## Test Plan - `python3 -m py_compile sdk/python/scripts/update_sdk_artifacts.py sdk/python/src/openai_codex/client.py sdk/python-runtime/src/codex_cli_bin/__init__.py` - `uv run --frozen --project sdk/python --extra dev ruff check sdk/python/scripts/update_sdk_artifacts.py sdk/python/src/openai_codex/client.py sdk/python/tests/test_artifact_workflow_and_binaries.py sdk/python-runtime/src/codex_cli_bin/__init__.py` - `uv run --frozen --project sdk/python --extra dev pytest sdk/python/tests/test_artifact_workflow_and_binaries.py` - `pnpm eslint src/exec.ts tests/exec.test.ts` - `pnpm test --runInBand tests/exec.test.ts`
This commit is contained in:
@@ -258,16 +258,12 @@ jobs:
|
||||
|
||||
stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}"
|
||||
wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}"
|
||||
# Keep the helpers next to codex.exe in the runtime wheel so Windows
|
||||
# sandbox/elevation lookup matches the standalone release zip.
|
||||
python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \
|
||||
stage-runtime \
|
||||
"$stage_dir" \
|
||||
"${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \
|
||||
"${RUNNER_TEMP}/codex-package-${{ matrix.target }}" \
|
||||
--codex-version "${GITHUB_REF_NAME}" \
|
||||
--platform-tag "$platform_tag" \
|
||||
--resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \
|
||||
--resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe"
|
||||
--platform-tag "$platform_tag"
|
||||
"${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
|
||||
|
||||
- name: Upload Python runtime wheel
|
||||
|
||||
@@ -569,18 +569,10 @@ jobs:
|
||||
"${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py"
|
||||
stage-runtime
|
||||
"$stage_dir"
|
||||
"${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex"
|
||||
"${RUNNER_TEMP}/codex-package-${{ matrix.target }}"
|
||||
--codex-version "${GITHUB_REF_NAME}"
|
||||
--platform-tag "$platform_tag"
|
||||
)
|
||||
if [[ "${{ matrix.target }}" == *linux* ]]; then
|
||||
# Keep bwrap in the runtime wheel so Linux sandbox fallback behavior
|
||||
# matches the standalone release bundle on hosts without system bwrap.
|
||||
stage_runtime_args+=(
|
||||
--resource-binary
|
||||
"${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap"
|
||||
)
|
||||
fi
|
||||
python3 "${stage_runtime_args[@]}"
|
||||
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
|
||||
|
||||
@@ -800,6 +792,20 @@ jobs:
|
||||
cp "$dmg_source" "$dest/$dmg_name"
|
||||
fi
|
||||
|
||||
- name: Build Codex package archive
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
BUNDLE: ${{ matrix.bundle }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
|
||||
--target "$TARGET" \
|
||||
--bundle "$BUNDLE" \
|
||||
--entrypoint-dir "dist/${TARGET}" \
|
||||
--archive-dir "dist/${TARGET}" \
|
||||
--target-suffixed-entrypoint
|
||||
|
||||
- name: Build Python runtime wheel
|
||||
if: ${{ matrix.bundle == 'primary' }}
|
||||
shell: bash
|
||||
@@ -828,25 +834,11 @@ jobs:
|
||||
"${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \
|
||||
stage-runtime \
|
||||
"$stage_dir" \
|
||||
"${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \
|
||||
"${RUNNER_TEMP}/codex-package-${{ matrix.target }}" \
|
||||
--codex-version "${GITHUB_REF_NAME}" \
|
||||
--platform-tag "$platform_tag"
|
||||
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
|
||||
|
||||
- name: Build Codex package archive
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
BUNDLE: ${{ matrix.bundle }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
|
||||
--target "$TARGET" \
|
||||
--bundle "$BUNDLE" \
|
||||
--entrypoint-dir "dist/${TARGET}" \
|
||||
--archive-dir "dist/${TARGET}" \
|
||||
--target-suffixed-entrypoint
|
||||
|
||||
- name: Upload Python runtime wheel
|
||||
if: ${{ matrix.bundle == 'primary' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
|
||||
@@ -36,7 +36,12 @@ exclude = [
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/codex_cli_bin"]
|
||||
include = ["src/codex_cli_bin/bin/**"]
|
||||
include = [
|
||||
"src/codex_cli_bin/codex-package.json",
|
||||
"src/codex_cli_bin/bin/**",
|
||||
"src/codex_cli_bin/codex-resources/**",
|
||||
"src/codex_cli_bin/codex-path/**",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel.hooks.custom]
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_NAME = "openai-codex-cli-bin"
|
||||
PACKAGE_METADATA_FILENAME = "codex-package.json"
|
||||
|
||||
|
||||
def bundled_package_dir() -> Path:
|
||||
path = Path(__file__).resolve().parent
|
||||
metadata_path = path / PACKAGE_METADATA_FILENAME
|
||||
if not metadata_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"{PACKAGE_NAME} is installed but missing its package metadata at {metadata_path}"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def bundled_codex_path() -> Path:
|
||||
exe = "codex.exe" if os.name == "nt" else "codex"
|
||||
path = Path(__file__).resolve().parent / "bin" / exe
|
||||
path = bundled_package_dir() / "bin" / exe
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}"
|
||||
@@ -16,4 +25,14 @@ def bundled_codex_path() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
__all__ = ["PACKAGE_NAME", "bundled_codex_path"]
|
||||
def bundled_path_dir() -> Path | None:
|
||||
path = bundled_package_dir() / "codex-path"
|
||||
return path if path.is_dir() else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PACKAGE_NAME",
|
||||
"bundled_codex_path",
|
||||
"bundled_package_dir",
|
||||
"bundled_path_dir",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
@@ -20,6 +19,14 @@ from typing import Any, Callable, Sequence, get_args, get_origin
|
||||
|
||||
SDK_DISTRIBUTION_NAME = "openai-codex"
|
||||
RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin"
|
||||
RUNTIME_PACKAGE_ROOT = Path("src") / "codex_cli_bin"
|
||||
CODEX_PACKAGE_METADATA = "codex-package.json"
|
||||
CODEX_PACKAGE_LAYOUT_ENTRIES = (
|
||||
CODEX_PACKAGE_METADATA,
|
||||
"bin",
|
||||
"codex-resources",
|
||||
"codex-path",
|
||||
)
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
@@ -53,15 +60,11 @@ def runtime_binary_name() -> str:
|
||||
|
||||
|
||||
def staged_runtime_bin_path(root: Path) -> Path:
|
||||
return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name()
|
||||
return root / RUNTIME_PACKAGE_ROOT / "bin" / runtime_binary_name()
|
||||
|
||||
|
||||
def staged_runtime_resource_path(root: Path, resource: Path) -> Path:
|
||||
"""Stage runtime helper binaries beside the main bundled Codex binary."""
|
||||
# Runtime wheels include the whole bin/ directory, so helper executables
|
||||
# should be staged beside the main Codex binary instead of changing the
|
||||
# package template for each platform.
|
||||
return root / "src" / "codex_cli_bin" / "bin" / resource.name
|
||||
def staged_runtime_package_root(root: Path) -> Path:
|
||||
return root / RUNTIME_PACKAGE_ROOT
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path) -> None:
|
||||
@@ -259,7 +262,7 @@ def stage_python_sdk_package(staging_dir: Path, codex_version: str) -> Path:
|
||||
def stage_python_runtime_package(
|
||||
staging_dir: Path,
|
||||
codex_version: str,
|
||||
binary_path: Path,
|
||||
runtime_source: Path,
|
||||
platform_tag: str | None = None,
|
||||
resource_binaries: Sequence[Path] = (),
|
||||
) -> Path:
|
||||
@@ -274,24 +277,76 @@ def stage_python_runtime_package(
|
||||
pyproject_text = _rewrite_runtime_platform_tag(pyproject_text, platform_tag)
|
||||
pyproject_path.write_text(pyproject_text)
|
||||
|
||||
out_bin = staged_runtime_bin_path(staging_dir)
|
||||
out_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(binary_path, out_bin)
|
||||
if not _is_windows():
|
||||
out_bin.chmod(out_bin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
for resource_binary in resource_binaries:
|
||||
# Some release targets need helper executables beside the main binary
|
||||
# (for example Linux bwrap or Windows sandbox helpers). Keep this
|
||||
# generic so release workflows own the platform-specific list.
|
||||
out_resource = staged_runtime_resource_path(staging_dir, resource_binary)
|
||||
shutil.copy2(resource_binary, out_resource)
|
||||
if not _is_windows():
|
||||
out_resource.chmod(
|
||||
out_resource.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
if runtime_source.is_dir():
|
||||
if resource_binaries:
|
||||
raise RuntimeError(
|
||||
"--resource-binary is only supported when staging a legacy loose runtime binary"
|
||||
)
|
||||
_copy_codex_package_layout(runtime_source, staged_runtime_package_root(staging_dir))
|
||||
else:
|
||||
runtime_package_root = staged_runtime_package_root(staging_dir)
|
||||
(runtime_package_root / "codex-resources").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_package_root / "codex-path").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_package_root / CODEX_PACKAGE_METADATA).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"layoutVersion": 1,
|
||||
"version": codex_version,
|
||||
"entrypoint": f"bin/{runtime_binary_name()}",
|
||||
"resourcesDir": "codex-resources",
|
||||
"pathDir": "codex-path",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
out_bin = staged_runtime_bin_path(staging_dir)
|
||||
out_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(runtime_source, out_bin)
|
||||
if not _is_windows():
|
||||
out_bin.chmod(out_bin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
for resource_binary in resource_binaries:
|
||||
# Compatibility path for older release workflows that stage helper
|
||||
# executables from a loose Codex binary instead of a package dir.
|
||||
out_resource = runtime_package_root / "codex-resources" / resource_binary.name
|
||||
shutil.copy2(resource_binary, out_resource)
|
||||
if not _is_windows():
|
||||
out_resource.chmod(
|
||||
out_resource.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
return staging_dir
|
||||
|
||||
|
||||
def _copy_codex_package_layout(package_dir: Path, runtime_package_root: Path) -> None:
|
||||
missing_entries = []
|
||||
if not (package_dir / CODEX_PACKAGE_METADATA).is_file():
|
||||
missing_entries.append(CODEX_PACKAGE_METADATA)
|
||||
for entry in ("bin", "codex-resources", "codex-path"):
|
||||
if not (package_dir / entry).is_dir():
|
||||
missing_entries.append(entry)
|
||||
package_binary = package_dir / "bin" / runtime_binary_name()
|
||||
if not package_binary.is_file():
|
||||
missing_entries.append(str(Path("bin") / runtime_binary_name()))
|
||||
if missing_entries:
|
||||
missing = ", ".join(missing_entries)
|
||||
raise RuntimeError(f"Missing Codex package layout entries in {package_dir}: {missing}")
|
||||
|
||||
for entry in CODEX_PACKAGE_LAYOUT_ENTRIES:
|
||||
src = package_dir / entry
|
||||
dest = runtime_package_root / entry
|
||||
if dest.exists():
|
||||
if dest.is_dir():
|
||||
shutil.rmtree(dest)
|
||||
else:
|
||||
dest.unlink()
|
||||
if src.is_dir():
|
||||
shutil.copytree(src, dest)
|
||||
else:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
|
||||
def _flatten_string_enum_one_of(definition: dict[str, Any]) -> bool:
|
||||
branches = definition.get("oneOf")
|
||||
if not isinstance(branches, list) or not branches:
|
||||
@@ -1220,7 +1275,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
stage_runtime_parser.add_argument(
|
||||
"runtime_binary",
|
||||
type=Path,
|
||||
help="Path to the codex binary to package for this platform",
|
||||
help=(
|
||||
"Path to a Codex package directory for this platform. For compatibility with "
|
||||
"older workflows, a loose codex binary path is also accepted."
|
||||
),
|
||||
)
|
||||
stage_runtime_parser.add_argument(
|
||||
"--codex-version",
|
||||
@@ -1245,7 +1303,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
help="Additional executable to package beside the codex runtime binary.",
|
||||
help=(
|
||||
"Additional executable to package beside a legacy loose codex runtime binary. "
|
||||
"Package-layout runtime sources should already contain their resources."
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -103,6 +101,29 @@ def _installed_codex_path() -> Path:
|
||||
return bundled_codex_path()
|
||||
|
||||
|
||||
def _installed_codex_path_dirs() -> tuple[Path, ...]:
|
||||
try:
|
||||
from codex_cli_bin import bundled_path_dir
|
||||
except (ImportError, AttributeError):
|
||||
return ()
|
||||
|
||||
path_dir = bundled_path_dir()
|
||||
return (path_dir,) if path_dir is not None else ()
|
||||
|
||||
|
||||
def _prepend_path_dirs(env: dict[str, str], path_dirs: tuple[Path, ...]) -> None:
|
||||
if not path_dirs:
|
||||
return
|
||||
|
||||
path_sep = os.pathsep
|
||||
existing_path = env.get("PATH", "")
|
||||
path_dir_values = [str(path_dir) for path_dir in path_dirs]
|
||||
existing_entries = [
|
||||
entry for entry in existing_path.split(path_sep) if entry and entry not in path_dir_values
|
||||
]
|
||||
env["PATH"] = path_sep.join([*path_dir_values, *existing_entries])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CodexBinResolverOps:
|
||||
installed_codex_path: Callable[[], Path]
|
||||
@@ -174,10 +195,13 @@ class AppServerClient:
|
||||
if self._proc is not None:
|
||||
return
|
||||
|
||||
path_dirs: tuple[Path, ...] = ()
|
||||
if self.config.launch_args_override is not None:
|
||||
args = list(self.config.launch_args_override)
|
||||
else:
|
||||
codex_bin = _resolve_codex_bin(self.config)
|
||||
if self.config.codex_bin is None:
|
||||
path_dirs = _installed_codex_path_dirs()
|
||||
args = [str(codex_bin)]
|
||||
for kv in self.config.config_overrides:
|
||||
args.extend(["--config", kv])
|
||||
@@ -186,6 +210,7 @@ class AppServerClient:
|
||||
env = os.environ.copy()
|
||||
if self.config.env:
|
||||
env.update(self.config.env)
|
||||
_prepend_path_dirs(env, path_dirs)
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
|
||||
@@ -4,6 +4,7 @@ import ast
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
@@ -324,7 +325,12 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() ->
|
||||
assert pyproject["project"]["name"] == "openai-codex-cli-bin"
|
||||
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == {
|
||||
"packages": ["src/codex_cli_bin"],
|
||||
"include": ["src/codex_cli_bin/bin/**"],
|
||||
"include": [
|
||||
"src/codex_cli_bin/codex-package.json",
|
||||
"src/codex_cli_bin/bin/**",
|
||||
"src/codex_cli_bin/codex-resources/**",
|
||||
"src/codex_cli_bin/codex-path/**",
|
||||
],
|
||||
"hooks": {"custom": {}},
|
||||
}
|
||||
assert pyproject["tool"]["hatch"]["build"]["targets"]["sdist"] == {
|
||||
@@ -350,11 +356,59 @@ def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) ->
|
||||
)
|
||||
|
||||
assert staged == tmp_path / "runtime-stage"
|
||||
assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n"
|
||||
package_root = script.staged_runtime_package_root(staged)
|
||||
assert {
|
||||
"codex": (package_root / "bin" / script.runtime_binary_name()).read_text(),
|
||||
"metadata": json.loads((package_root / "codex-package.json").read_text()),
|
||||
"resources_dir": (package_root / "codex-resources").is_dir(),
|
||||
"path_dir": (package_root / "codex-path").is_dir(),
|
||||
} == {
|
||||
"codex": "fake codex\n",
|
||||
"metadata": {
|
||||
"layoutVersion": 1,
|
||||
"version": "1.2.3",
|
||||
"entrypoint": f"bin/{script.runtime_binary_name()}",
|
||||
"resourcesDir": "codex-resources",
|
||||
"pathDir": "codex-path",
|
||||
},
|
||||
"resources_dir": True,
|
||||
"path_dir": True,
|
||||
}
|
||||
assert 'name = "openai-codex-cli-bin"' in (staged / "pyproject.toml").read_text()
|
||||
assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text()
|
||||
|
||||
|
||||
def test_stage_runtime_release_copies_package_layout(tmp_path: Path) -> None:
|
||||
script = _load_update_script_module()
|
||||
package_dir = tmp_path / "codex-package"
|
||||
(package_dir / "bin").mkdir(parents=True)
|
||||
(package_dir / "codex-resources").mkdir()
|
||||
(package_dir / "codex-path").mkdir()
|
||||
(package_dir / "codex-package.json").write_text('{"variant":"codex"}\n')
|
||||
(package_dir / "bin" / script.runtime_binary_name()).write_text("fake codex\n")
|
||||
(package_dir / "codex-resources" / "bwrap").write_text("fake bwrap\n")
|
||||
(package_dir / "codex-path" / "rg").write_text("fake rg\n")
|
||||
|
||||
staged = script.stage_python_runtime_package(
|
||||
tmp_path / "runtime-stage",
|
||||
"1.2.3",
|
||||
package_dir,
|
||||
)
|
||||
package_root = script.staged_runtime_package_root(staged)
|
||||
|
||||
assert {
|
||||
"metadata": (package_root / "codex-package.json").read_text(),
|
||||
"codex": (package_root / "bin" / script.runtime_binary_name()).read_text(),
|
||||
"bwrap": (package_root / "codex-resources" / "bwrap").read_text(),
|
||||
"rg": (package_root / "codex-path" / "rg").read_text(),
|
||||
} == {
|
||||
"metadata": '{"variant":"codex"}\n',
|
||||
"codex": "fake codex\n",
|
||||
"bwrap": "fake bwrap\n",
|
||||
"rg": "fake rg\n",
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_codex_version_accepts_release_tags_and_pep440_versions() -> None:
|
||||
script = _load_update_script_module()
|
||||
|
||||
@@ -402,7 +456,7 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non
|
||||
|
||||
|
||||
def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None:
|
||||
"""Runtime staging should copy every helper binary into the wheel bin dir."""
|
||||
"""Runtime staging should copy every helper binary into codex-resources."""
|
||||
script = _load_update_script_module()
|
||||
fake_binary = tmp_path / script.runtime_binary_name()
|
||||
helper = tmp_path / "helper"
|
||||
@@ -419,10 +473,11 @@ def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
assert {
|
||||
path.relative_to(staged / "src" / "codex_cli_bin" / "bin").as_posix(): path.read_text()
|
||||
for path in (staged / "src" / "codex_cli_bin" / "bin").iterdir()
|
||||
path.relative_to(
|
||||
staged / "src" / "codex_cli_bin" / "codex-resources"
|
||||
).as_posix(): path.read_text()
|
||||
for path in (staged / "src" / "codex_cli_bin" / "codex-resources").iterdir()
|
||||
} == {
|
||||
script.runtime_binary_name(): "fake codex\n",
|
||||
"fallback-helper": "fake fallback\n",
|
||||
"helper": "fake helper\n",
|
||||
}
|
||||
@@ -431,7 +486,7 @@ def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None:
|
||||
def test_runtime_resource_binaries_are_included_by_wheel_config(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The runtime wheel config should include helper binaries beside Codex."""
|
||||
"""The runtime wheel config should include helper binaries in codex-resources."""
|
||||
script = _load_update_script_module()
|
||||
fake_binary = tmp_path / script.runtime_binary_name()
|
||||
helper = tmp_path / "helper"
|
||||
@@ -448,9 +503,14 @@ def test_runtime_resource_binaries_are_included_by_wheel_config(
|
||||
pyproject = tomllib.loads((staged / "pyproject.toml").read_text())
|
||||
assert {
|
||||
"include": pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["include"],
|
||||
"helper": (staged / "src" / "codex_cli_bin" / "bin" / "helper").read_text(),
|
||||
"helper": (staged / "src" / "codex_cli_bin" / "codex-resources" / "helper").read_text(),
|
||||
} == {
|
||||
"include": ["src/codex_cli_bin/bin/**"],
|
||||
"include": [
|
||||
"src/codex_cli_bin/codex-package.json",
|
||||
"src/codex_cli_bin/bin/**",
|
||||
"src/codex_cli_bin/codex-resources/**",
|
||||
"src/codex_cli_bin/codex-path/**",
|
||||
],
|
||||
"helper": "fake helper\n",
|
||||
}
|
||||
|
||||
@@ -653,6 +713,17 @@ def test_default_runtime_is_resolved_from_installed_runtime_package(
|
||||
assert client_module.resolve_codex_bin(config, ops) == fake_binary
|
||||
|
||||
|
||||
def test_runtime_path_dir_is_prepended_without_duplicates(tmp_path: Path) -> None:
|
||||
from openai_codex import client as client_module
|
||||
|
||||
path_dir = tmp_path / "codex-path"
|
||||
env = {"PATH": os.pathsep.join(["/usr/bin", str(path_dir), "/bin"])}
|
||||
|
||||
client_module._prepend_path_dirs(env, (path_dir,))
|
||||
|
||||
assert env["PATH"] == os.pathsep.join([str(path_dir), "/usr/bin", "/bin"])
|
||||
|
||||
|
||||
def test_explicit_codex_bin_override_takes_priority(tmp_path: Path) -> None:
|
||||
from openai_codex import client as client_module
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -54,8 +55,14 @@ const PLATFORM_PACKAGE_BY_TARGET: Record<string, string> = {
|
||||
|
||||
const moduleRequire = createRequire(import.meta.url);
|
||||
|
||||
type CodexPathResolution = {
|
||||
executablePath: string;
|
||||
pathDirs: string[];
|
||||
};
|
||||
|
||||
export class CodexExec {
|
||||
private executablePath: string;
|
||||
private pathDirs: string[];
|
||||
private envOverride?: Record<string, string>;
|
||||
private configOverrides?: CodexConfigObject;
|
||||
|
||||
@@ -64,7 +71,14 @@ export class CodexExec {
|
||||
env?: Record<string, string>,
|
||||
configOverrides?: CodexConfigObject,
|
||||
) {
|
||||
this.executablePath = executablePath || findCodexPath();
|
||||
if (executablePath) {
|
||||
this.executablePath = executablePath;
|
||||
this.pathDirs = [];
|
||||
} else {
|
||||
const resolved = findCodexPath();
|
||||
this.executablePath = resolved.executablePath;
|
||||
this.pathDirs = resolved.pathDirs;
|
||||
}
|
||||
this.envOverride = env;
|
||||
this.configOverrides = configOverrides;
|
||||
}
|
||||
@@ -160,6 +174,9 @@ export class CodexExec {
|
||||
if (args.apiKey) {
|
||||
env.CODEX_API_KEY = args.apiKey;
|
||||
}
|
||||
if (this.pathDirs.length > 0) {
|
||||
env.PATH = prependPathDirs(env.PATH, this.pathDirs);
|
||||
}
|
||||
|
||||
const child = spawn(this.executablePath, commandArgs, {
|
||||
env,
|
||||
@@ -314,7 +331,7 @@ function isPlainObject(value: unknown): value is CodexConfigObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function findCodexPath() {
|
||||
function findCodexPath(): CodexPathResolution {
|
||||
const { platform, arch } = process;
|
||||
|
||||
let targetTriple = null;
|
||||
@@ -381,9 +398,65 @@ function findCodexPath() {
|
||||
);
|
||||
}
|
||||
|
||||
const archRoot = path.join(vendorRoot, targetTriple);
|
||||
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
|
||||
const binaryPath = path.join(archRoot, "codex", codexBinaryName);
|
||||
const nativePackage = resolveNativePackage(vendorRoot, targetTriple, codexBinaryName);
|
||||
if (!nativePackage) {
|
||||
throw new Error(
|
||||
`Unable to locate Codex CLI binaries for ${targetTriple}. Ensure ${CODEX_NPM_NAME} is installed with optional dependencies.`,
|
||||
);
|
||||
}
|
||||
|
||||
return binaryPath;
|
||||
return nativePackage;
|
||||
}
|
||||
|
||||
export function resolveNativePackage(
|
||||
vendorRoot: string,
|
||||
targetTriple: string,
|
||||
codexBinaryName: string,
|
||||
): CodexPathResolution | null {
|
||||
const packageRoot = path.join(vendorRoot, targetTriple);
|
||||
const packageBinaryPath = path.join(packageRoot, "bin", codexBinaryName);
|
||||
if (isFile(packageBinaryPath) && isFile(path.join(packageRoot, "codex-package.json"))) {
|
||||
return {
|
||||
executablePath: packageBinaryPath,
|
||||
pathDirs: existingDirs(path.join(packageRoot, "codex-path")),
|
||||
};
|
||||
}
|
||||
|
||||
const legacyBinaryPath = path.join(packageRoot, "codex", codexBinaryName);
|
||||
if (isFile(legacyBinaryPath)) {
|
||||
return {
|
||||
executablePath: legacyBinaryPath,
|
||||
pathDirs: existingDirs(path.join(packageRoot, "path")),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function existingDirs(...dirs: string[]): string[] {
|
||||
return dirs.filter(isDirectory);
|
||||
}
|
||||
|
||||
export function prependPathDirs(existingPath: string | undefined, pathDirs: string[]): string {
|
||||
const existingEntries = (existingPath ?? "")
|
||||
.split(path.delimiter)
|
||||
.filter((entry) => entry.length > 0 && !pathDirs.includes(entry));
|
||||
return [...pathDirs, ...existingEntries].join(path.delimiter);
|
||||
}
|
||||
|
||||
function isFile(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as child_process from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
@@ -142,4 +145,46 @@ describe("CodexExec", () => {
|
||||
delete process.env.CODEX_ENV_SHOULD_NOT_LEAK;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the package-layout binary and PATH directory", async () => {
|
||||
const { resolveNativePackage } = await import("../src/exec");
|
||||
const vendorRoot = mkdtempSync(path.join(tmpdir(), "codex-sdk-vendor-"));
|
||||
const packageRoot = path.join(vendorRoot, "x86_64-unknown-linux-musl");
|
||||
const binDir = path.join(packageRoot, "bin");
|
||||
const pathDir = path.join(packageRoot, "codex-path");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(pathDir, { recursive: true });
|
||||
writeFileSync(path.join(packageRoot, "codex-package.json"), "{}");
|
||||
writeFileSync(path.join(binDir, "codex"), "");
|
||||
|
||||
expect(resolveNativePackage(vendorRoot, "x86_64-unknown-linux-musl", "codex")).toEqual({
|
||||
executablePath: path.join(binDir, "codex"),
|
||||
pathDirs: [pathDir],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the legacy binary layout", async () => {
|
||||
const { resolveNativePackage } = await import("../src/exec");
|
||||
const vendorRoot = mkdtempSync(path.join(tmpdir(), "codex-sdk-vendor-"));
|
||||
const packageRoot = path.join(vendorRoot, "x86_64-unknown-linux-musl");
|
||||
const binDir = path.join(packageRoot, "codex");
|
||||
const pathDir = path.join(packageRoot, "path");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(pathDir, { recursive: true });
|
||||
writeFileSync(path.join(binDir, "codex"), "");
|
||||
|
||||
expect(resolveNativePackage(vendorRoot, "x86_64-unknown-linux-musl", "codex")).toEqual({
|
||||
executablePath: path.join(binDir, "codex"),
|
||||
pathDirs: [pathDir],
|
||||
});
|
||||
});
|
||||
|
||||
it("prepends package PATH entries without duplicating them", async () => {
|
||||
const { prependPathDirs } = await import("../src/exec");
|
||||
const pathDir = path.join(tmpdir(), "codex-path");
|
||||
|
||||
expect(prependPathDirs(`/usr/bin${path.delimiter}${pathDir}`, [pathDir])).toBe(
|
||||
`${pathDir}${path.delimiter}/usr/bin`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user