diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b2403b749cc2..84987c0f0200 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,34 +1,42 @@ -# Workflow Strategy +# Codex Lab Workflow Strategy -The workflows in this directory are split so that pull requests get fast, review-friendly signal while `main` still gets the full cross-platform verification pass. +This fork keeps upstream workflows available, but the automatic PR signal is +owned by Codex Lab. Upstream's full CI and release workflows assume OpenAI +runner groups, secrets, and release infrastructure that this fork does not own. ## Pull Requests -- `bazel.yml` is the main pre-merge verification path for Rust code. - It runs Bazel `test` and Bazel `clippy` on the supported Bazel targets, - including the generated Rust test binaries needed to lint inline `#[cfg(test)]` - code. -- `rust-ci.yml` keeps the Cargo-native PR checks intentionally small: - - `cargo fmt --check` - - `cargo shear` - - `argument-comment-lint` on Linux, macOS, and Windows - - `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes - -## Post-Merge On `main` - -- `bazel.yml` also runs on pushes to `main`. - This re-verifies the merged Bazel path and helps keep the BuildBuddy caches warm. -- `rust-ci-full.yml` is the full Cargo-native verification workflow. - It keeps the heavier checks off the PR path while still validating them after merge: - - the full Cargo `clippy` matrix - - the full Cargo `nextest` matrix via per-platform archive-backed shards - - Windows ARM64 nextest archives cross-compiled on Windows x64, then replayed on native Windows ARM64 shards - - release-profile Cargo builds - - cross-platform `argument-comment-lint` - - Linux remote-env tests - -## Rule Of Thumb - -- If a build/test/clippy check can be expressed in Bazel, prefer putting the PR-time version in `bazel.yml`. -- Keep `rust-ci.yml` fast enough that it usually does not dominate PR latency. -- Reserve `rust-ci-full.yml` for heavyweight Cargo-native coverage that Bazel does not replace yet. +- `ci.yml` runs cheap repository sanity checks plus Codex Lab package-builder + unit and smoke tests. +- `codex-lab-app.yml` builds the macOS ARM64 `Codex Lab.app` artifact on the + self-hosted macOS runner when packaging files, Rust CLI code, or the workflow + change. The self-hosted job is guarded so it runs automatically only for + branches in this repository or manual dispatches. +- `blob-size-policy.yml`, `codespell.yml`, and `cargo-deny.yml` are retained as + lightweight inherited checks while they remain fork-safe. + +## Manual Upstream Parity Checks + +The inherited heavyweight workflows are `workflow_dispatch` only in this fork: + +- `bazel.yml` +- `rust-ci.yml` +- `sdk.yml` +- `v8-canary.yml` + +Run these manually when a change needs upstream-style validation or touches the +areas those workflows own. Keep them out of the default PR path until this fork +has matching runner capacity, secrets, and branch-protection expectations. + +## Local Runner Contract + +`codex-lab-app.yml` expects a self-hosted macOS ARM64 runner with these labels: + +- `self-hosted` +- `macOS` +- `ARM64` +- `codex-lab-app` + +The current local runner is `chris-mac-codex-release-1`. It must have Rust, +Python 3, Xcode command line tools, and macOS `ditto` available. The generated +Codex Lab app artifact is currently unsigned. diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index cc1ef0924553..ce568407d7a6 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -4,10 +4,6 @@ name: Bazel # https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml on: - pull_request: {} - push: - branches: - - main workflow_dispatch: concurrency: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63c6ffe5206d..0aebeb7dd524 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,23 @@ jobs: - name: Test Codex package builder run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py' + - name: Test Codex Lab app package builder + run: python3 -m unittest discover -s scripts/codex_lab_package -p 'test_*.py' + + - name: Smoke test Codex Lab app package layout + shell: bash + run: | + set -euo pipefail + output_dir="${RUNNER_TEMP}/codex-lab-smoke" + python3 scripts/build_codex_lab_app.py \ + --codex-bin /bin/sh \ + --app-dir "${output_dir}/Codex Lab.app" \ + --shim-dir "${output_dir}/bin" \ + --force + python3 scripts/codex_lab_package/smoke.py \ + "${output_dir}/Codex Lab.app" \ + --shim-path "${output_dir}/bin/codex-lab" + - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: diff --git a/.github/workflows/codex-lab-app.yml b/.github/workflows/codex-lab-app.yml new file mode 100644 index 000000000000..bffb1401c95e --- /dev/null +++ b/.github/workflows/codex-lab-app.yml @@ -0,0 +1,81 @@ +name: codex-lab-app + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/codex-lab-app.yml" + - "scripts/build_codex_lab_app.py" + - "scripts/codex_lab_package/**" + - "codex-rs/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-macos-aarch64: + name: Build macOS ARM64 Codex Lab app + if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: + - self-hosted + - macOS + - ARM64 + - codex-lab-app + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + + - name: Build Codex Lab CLI + working-directory: codex-rs + shell: bash + run: cargo build --release -p codex-cli --bin codex + + - name: Build Codex Lab app bundle + id: package + shell: bash + run: | + set -euo pipefail + output_root="${RUNNER_TEMP}/codex-lab-app" + app_dir="${output_root}/Codex Lab.app" + shim_dir="${output_root}/bin" + mkdir -p "$output_root" + python3 scripts/build_codex_lab_app.py \ + --codex-bin codex-rs/target/release/codex \ + --app-dir "$app_dir" \ + --shim-dir "$shim_dir" \ + --force + python3 scripts/codex_lab_package/smoke.py \ + "$app_dir" \ + --shim-path "${shim_dir}/codex-lab" + echo "output_root=$output_root" >> "$GITHUB_OUTPUT" + + - name: Archive Codex Lab app artifact + id: archive + shell: bash + run: | + set -euo pipefail + output_root="${{ steps.package.outputs.output_root }}" + dist_dir="${RUNNER_TEMP}/codex-lab-dist" + mkdir -p "$dist_dir" + app_zip="${dist_dir}/codex-lab-app-aarch64-apple-darwin.zip" + shim_zip="${dist_dir}/codex-lab-shim-aarch64-apple-darwin.zip" + ditto -c -k --norsrc --keepParent "${output_root}/Codex Lab.app" "$app_zip" + ditto -c -k --norsrc --keepParent "${output_root}/bin/codex-lab" "$shim_zip" + (cd "$dist_dir" && shasum -a 256 -- *.zip > SHA256SUMS) + cat "${dist_dir}/SHA256SUMS" + echo "dist_dir=$dist_dir" >> "$GITHUB_OUTPUT" + + - name: Upload Codex Lab app artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: codex-lab-app-aarch64-apple-darwin + path: ${{ steps.archive.outputs.dist_dir }}/* + if-no-files-found: error diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 9b50ae403261..1965a9f48fd9 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,6 +1,5 @@ name: rust-ci on: - pull_request: {} workflow_dispatch: # Cargo's libgit2 transport has been flaky when fetching git dependencies with diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 4103a948cd03..a179dfcb026a 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -1,9 +1,7 @@ name: sdk on: - push: - branches: [main] - pull_request: {} + workflow_dispatch: jobs: python-sdk: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index c590dec00f52..a953e16e3467 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -1,42 +1,6 @@ name: v8-canary on: - pull_request: - paths: - - ".bazelrc" - - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - - ".github/scripts/rusty_v8_bazel.py" - - ".github/scripts/rusty_v8_module_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/llvm_*.patch" - - "patches/rules_cc_*.patch" - - "patches/v8_*.patch" - - "third_party/v8/**" - push: - branches: - - main - paths: - - ".bazelrc" - - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - - ".github/scripts/rusty_v8_bazel.py" - - ".github/scripts/rusty_v8_module_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/llvm_*.patch" - - "patches/rules_cc_*.patch" - - "patches/v8_*.patch" - - "third_party/v8/**" workflow_dispatch: # Cargo's libgit2 transport has been flaky when fetching git dependencies with diff --git a/scripts/build_codex_lab_app.py b/scripts/build_codex_lab_app.py new file mode 100755 index 000000000000..553af6b53d5c --- /dev/null +++ b/scripts/build_codex_lab_app.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Build a Codex Lab macOS launcher app bundle.""" + +from pathlib import Path +import sys + + +# Some developer environments set PYTHONSAFEPATH=1, which prevents Python from +# adding the script directory to sys.path. Add it explicitly so the local helper +# package remains importable when this executable is launched from any cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from codex_lab_package.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex_lab_package/README.md b/scripts/codex_lab_package/README.md new file mode 100644 index 000000000000..0de4a80c88e5 --- /dev/null +++ b/scripts/codex_lab_package/README.md @@ -0,0 +1,19 @@ +# Codex Lab Desktop Launcher Packaging + +This helper builds a macOS `Codex Lab.app` launcher bundle. The bundle does not +contain or modify OpenAI's Codex Desktop app. Instead, it embeds a Codex Lab CLI +binary, sets `CODEX_CLI_PATH` to that binary, and launches the official +`/Applications/Codex.app` through LaunchServices. + +Example: + +```shell +scripts/build_codex_lab_app.py \ + --codex-bin codex-rs/target/release/codex \ + --app-dir /tmp/Codex\ Lab.app \ + --shim-dir /tmp/codex-lab-bin \ + --force +``` + +The optional shim directory receives a `codex-lab` wrapper that executes the +same embedded binary used by Desktop mode. diff --git a/scripts/codex_lab_package/__init__.py b/scripts/codex_lab_package/__init__.py new file mode 100644 index 000000000000..a34b566ce56a --- /dev/null +++ b/scripts/codex_lab_package/__init__.py @@ -0,0 +1 @@ +"""Helpers for packaging the Codex Lab desktop launcher.""" diff --git a/scripts/codex_lab_package/cli.py b/scripts/codex_lab_package/cli.py new file mode 100644 index 000000000000..891b1f65221b --- /dev/null +++ b/scripts/codex_lab_package/cli.py @@ -0,0 +1,99 @@ +"""Command-line interface for building Codex Lab launcher bundles.""" + +import argparse +import tempfile +from pathlib import Path + +from .layout import DEFAULT_BUNDLE_IDENTIFIER +from .layout import DEFAULT_CODEX_APP_PATH +from .layout import DEFAULT_DISPLAY_NAME +from .layout import CodexLabAppOptions +from .layout import build_codex_lab_app + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build a Codex Lab macOS launcher app bundle.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--codex-bin", + type=Path, + required=True, + help="Prebuilt Codex Lab CLI executable to embed in the app bundle.", + ) + parser.add_argument( + "--app-dir", + type=Path, + default=argparse.SUPPRESS, + help=( + "Output .app directory. Defaults to a new temporary " + "directory named Codex Lab.app." + ), + ) + parser.add_argument( + "--shim-dir", + type=Path, + help="Optional directory where a codex-lab wrapper should be installed.", + ) + parser.add_argument( + "--codex-app-path", + type=Path, + default=DEFAULT_CODEX_APP_PATH, + help="Official Codex Desktop app path to launch.", + ) + parser.add_argument( + "--bundle-id", + default=DEFAULT_BUNDLE_IDENTIFIER, + help="CFBundleIdentifier for the launcher bundle.", + ) + parser.add_argument( + "--display-name", + default=DEFAULT_DISPLAY_NAME, + help="Display name for the launcher bundle.", + ) + parser.add_argument( + "--short-version", + default="0.0.0", + help="CFBundleShortVersionString for the launcher bundle.", + ) + parser.add_argument( + "--bundle-version", + default="1", + help="CFBundleVersion for the launcher bundle.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace an existing app bundle or shim.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + app_dir_arg = getattr(args, "app_dir", None) + app_dir = ( + app_dir_arg.resolve() + if app_dir_arg is not None + else Path(tempfile.mkdtemp(prefix="codex-lab-app-")) / "Codex Lab.app" + ) + + result = build_codex_lab_app( + CodexLabAppOptions( + app_dir=app_dir, + codex_bin=args.codex_bin.resolve(), + codex_app_path=args.codex_app_path, + shim_dir=args.shim_dir.resolve() if args.shim_dir else None, + bundle_identifier=args.bundle_id, + display_name=args.display_name, + short_version=args.short_version, + bundle_version=args.bundle_version, + force=args.force, + ) + ) + + print(f"Built Codex Lab app bundle at {result.app_dir}") + if result.shim_path is not None: + print(f"Installed codex-lab shim at {result.shim_path}") + return 0 diff --git a/scripts/codex_lab_package/layout.py b/scripts/codex_lab_package/layout.py new file mode 100644 index 000000000000..fe985fb43205 --- /dev/null +++ b/scripts/codex_lab_package/layout.py @@ -0,0 +1,191 @@ +"""Build the macOS Codex Lab launcher app bundle layout.""" + +from dataclasses import dataclass +from pathlib import Path +import plistlib +import shutil +import stat + + +DEFAULT_BUNDLE_IDENTIFIER = "dev.everycode.codex-lab" +DEFAULT_CODEX_APP_PATH = Path("/Applications/Codex.app") +DEFAULT_DISPLAY_NAME = "Codex Lab" +EMBEDDED_CLI_NAME = "codex-lab" +LAUNCHER_NAME = "Codex Lab Launcher" +SHIM_NAME = "codex-lab" + + +@dataclass(frozen=True) +class CodexLabAppOptions: + app_dir: Path + codex_bin: Path + codex_app_path: Path = DEFAULT_CODEX_APP_PATH + shim_dir: Path | None = None + bundle_identifier: str = DEFAULT_BUNDLE_IDENTIFIER + display_name: str = DEFAULT_DISPLAY_NAME + short_version: str = "0.0.0" + bundle_version: str = "1" + force: bool = False + + +@dataclass(frozen=True) +class CodexLabAppResult: + app_dir: Path + embedded_cli_path: Path + launcher_path: Path + shim_path: Path | None + + +def build_codex_lab_app(options: CodexLabAppOptions) -> CodexLabAppResult: + codex_bin = options.codex_bin.resolve() + if not codex_bin.is_file(): + raise FileNotFoundError(f"Codex Lab CLI executable does not exist: {codex_bin}") + + app_dir = options.app_dir.resolve() + _prepare_output_path(app_dir, options.force) + + contents_dir = app_dir / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + macos_dir.mkdir(parents=True) + resources_dir.mkdir(parents=True) + + embedded_cli_path = resources_dir / EMBEDDED_CLI_NAME + shutil.copy(codex_bin, embedded_cli_path) + _make_executable(embedded_cli_path) + + launcher_path = macos_dir / LAUNCHER_NAME + with launcher_path.open("w", encoding="utf-8") as handle: + print( + _launcher_script( + embedded_cli_path=embedded_cli_path, + codex_app_path=options.codex_app_path, + ), + end="", + file=handle, + ) + _make_executable(launcher_path) + + _write_info_plist(contents_dir / "Info.plist", options) + shim_path = _install_shim( + options.shim_dir, + options.force, + ) + + return CodexLabAppResult( + app_dir=app_dir, + embedded_cli_path=embedded_cli_path, + launcher_path=launcher_path, + shim_path=shim_path, + ) + + +def _prepare_output_path(path: Path, force: bool) -> None: + if not path.exists() and not path.is_symlink(): + return + if not force: + raise FileExistsError(f"Output already exists: {path}") + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def _make_executable(path: Path) -> None: + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _launcher_script(*, embedded_cli_path: Path, codex_app_path: Path) -> str: + embedded_cli_name = embedded_cli_path.name + return f"""#!/bin/sh +set -eu + +APP_CONTENTS_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +LAB_CLI="$APP_CONTENTS_DIR/Resources/{embedded_cli_name}" +CODEX_APP={_shell_quote(str(codex_app_path))} + +if [ ! -x "$LAB_CLI" ]; then + echo "Codex Lab CLI is not executable: $LAB_CLI" >&2 + exit 1 +fi + +if [ ! -d "$CODEX_APP" ]; then + echo "Codex Desktop app was not found: $CODEX_APP" >&2 + exit 1 +fi + +export CODEX_CLI_PATH="$LAB_CLI" +exec open -n --env "CODEX_CLI_PATH=$LAB_CLI" "$CODEX_APP" +""" + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\\''") + "'" + + +def _write_info_plist(path: Path, options: CodexLabAppOptions) -> None: + info = { + "CFBundleDisplayName": options.display_name, + "CFBundleExecutable": LAUNCHER_NAME, + "CFBundleIdentifier": options.bundle_identifier, + "CFBundleName": options.display_name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": options.short_version, + "CFBundleVersion": options.bundle_version, + "LSMinimumSystemVersion": "13.0", + "NSHighResolutionCapable": True, + } + with path.open("wb") as handle: + plistlib.dump(info, handle, sort_keys=True) + + +def _install_shim( + shim_dir: Path | None, + force: bool, +) -> Path | None: + if shim_dir is None: + return None + + shim_dir.mkdir(parents=True, exist_ok=True) + shim_path = shim_dir / SHIM_NAME + if shim_path.exists() or shim_path.is_symlink(): + if not force: + raise FileExistsError(f"Shim already exists: {shim_path}") + _prepare_output_path(shim_path, force) + + with shim_path.open("w", encoding="utf-8") as handle: + print(_shim_script(), end="", file=handle) + _make_executable(shim_path) + return shim_path + + +def _shim_script() -> str: + return """#!/bin/sh +set -eu + +candidate_apps="${CODEX_LAB_APP_PATH:-} +/Applications/Codex Lab.app +$HOME/Applications/Codex Lab.app" + +LAB_CLI= +while IFS= read -r app_path; do + if [ -z "$app_path" ]; then + continue + fi + candidate_cli="$app_path/Contents/Resources/codex-lab" + if [ -x "$candidate_cli" ]; then + LAB_CLI="$candidate_cli" + break + fi +done <&2 + exit 1 +fi + +exec "$LAB_CLI" "$@" +""" diff --git a/scripts/codex_lab_package/smoke.py b/scripts/codex_lab_package/smoke.py new file mode 100644 index 000000000000..28bfeec10b62 --- /dev/null +++ b/scripts/codex_lab_package/smoke.py @@ -0,0 +1,99 @@ +"""Smoke checks for generated Codex Lab launcher bundles.""" + +import argparse +import plistlib +import subprocess +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Smoke check a generated Codex Lab app bundle.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("app_dir", type=Path, help="Codex Lab.app directory to check.") + parser.add_argument( + "--shim-path", + type=Path, + help="Optional codex-lab terminal wrapper path to check.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + smoke_check( + args.app_dir.resolve(), args.shim_path.resolve() if args.shim_path else None + ) + return 0 + + +def smoke_check(app_dir: Path, shim_path: Path | None) -> None: + contents_dir = app_dir / "Contents" + launcher_path = contents_dir / "MacOS" / "Codex Lab Launcher" + embedded_cli_path = contents_dir / "Resources" / "codex-lab" + info_plist_path = contents_dir / "Info.plist" + + _require_directory(app_dir) + _require_executable(launcher_path) + _require_executable(embedded_cli_path) + _require_file(info_plist_path) + _check_plist(info_plist_path) + _check_shell_syntax(launcher_path) + + launcher = launcher_path.read_text(encoding="utf-8") + _require_contains(launcher, "CODEX_CLI_PATH", launcher_path) + _require_contains(launcher, "Resources/codex-lab", launcher_path) + _require_contains(launcher, "open -n", launcher_path) + _require_contains(launcher, "--env", launcher_path) + + if shim_path is not None: + _require_executable(shim_path) + _check_shell_syntax(shim_path) + shim = shim_path.read_text(encoding="utf-8") + _require_contains(shim, "CODEX_LAB_APP_PATH", shim_path) + _require_contains(shim, "/Applications/Codex Lab.app", shim_path) + + +def _require_directory(path: Path) -> None: + if not path.is_dir(): + raise FileNotFoundError(f"Expected directory: {path}") + + +def _require_file(path: Path) -> None: + if not path.is_file(): + raise FileNotFoundError(f"Expected file: {path}") + + +def _require_executable(path: Path) -> None: + _require_file(path) + if not path.stat().st_mode & 0o111: + raise PermissionError(f"Expected executable file: {path}") + + +def _check_plist(path: Path) -> None: + with path.open("rb") as handle: + info = plistlib.load(handle) + + expected = { + "CFBundleDisplayName": "Codex Lab", + "CFBundleExecutable": "Codex Lab Launcher", + "CFBundleName": "Codex Lab", + "CFBundlePackageType": "APPL", + } + for key, value in expected.items(): + if info.get(key) != value: + raise ValueError(f"{path}: expected {key}={value!r}, got {info.get(key)!r}") + + +def _check_shell_syntax(path: Path) -> None: + subprocess.run(["sh", "-n", str(path)], check=True) + + +def _require_contains(contents: str, needle: str, path: Path) -> None: + if needle not in contents: + raise ValueError(f"{path}: missing {needle!r}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex_lab_package/test_layout.py b/scripts/codex_lab_package/test_layout.py new file mode 100644 index 000000000000..d1aa73c8b7e3 --- /dev/null +++ b/scripts/codex_lab_package/test_layout.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import os +import plistlib +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_lab_package.layout import CodexLabAppOptions +from codex_lab_package.layout import build_codex_lab_app + + +class BuildCodexLabAppTest(unittest.TestCase): + def test_builds_launcher_bundle_with_embedded_cli_and_shim(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + codex_bin = root / "fake-codex" + codex_bin.write_text('#!/bin/sh\nexec /bin/sh "$@"\n', encoding="utf-8") + os.chmod(codex_bin, 0o755) + + result = build_codex_lab_app( + CodexLabAppOptions( + app_dir=root / "Codex Lab.app", + codex_bin=codex_bin, + codex_app_path=Path("/Applications/Codex.app"), + shim_dir=root / "bin", + bundle_identifier="dev.example.codex-lab-test", + short_version="1.2.3", + bundle_version="42", + ) + ) + + self.assertEqual(result.app_dir, (root / "Codex Lab.app").resolve()) + self.assertEqual( + result.embedded_cli_path, + (root / "Codex Lab.app/Contents/Resources/codex-lab").resolve(), + ) + self.assertEqual( + result.launcher_path, + (root / "Codex Lab.app/Contents/MacOS/Codex Lab Launcher").resolve(), + ) + self.assertEqual(result.shim_path, root / "bin/codex-lab") + self.assertIsNotNone(result.shim_path) + assert result.shim_path is not None + self.assertFalse(result.shim_path.is_symlink()) + + info_plist = root / "Codex Lab.app/Contents/Info.plist" + with info_plist.open("rb") as handle: + info = plistlib.load(handle) + self.assertEqual( + info, + { + "CFBundleDisplayName": "Codex Lab", + "CFBundleExecutable": "Codex Lab Launcher", + "CFBundleIdentifier": "dev.example.codex-lab-test", + "CFBundleName": "Codex Lab", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "42", + "LSMinimumSystemVersion": "13.0", + "NSHighResolutionCapable": True, + }, + ) + + launcher = result.launcher_path.read_text(encoding="utf-8") + self.assertIn("CODEX_CLI_PATH", launcher) + self.assertIn("APP_CONTENTS_DIR", launcher) + self.assertIn("Resources/codex-lab", launcher) + self.assertIn("open -n", launcher) + self.assertIn("--env", launcher) + + shim = result.shim_path.read_text(encoding="utf-8") + self.assertNotIn(str(result.embedded_cli_path), shim) + self.assertIn("CODEX_LAB_APP_PATH", shim) + self.assertIn("/Applications/Codex Lab.app", shim) + self.assertIn('exec "$LAB_CLI" "$@"', shim) + + completed = subprocess.run( + [str(result.shim_path), "-c", "printf shim-ok"], + check=True, + env={**os.environ, "CODEX_LAB_APP_PATH": str(result.app_dir)}, + stdout=subprocess.PIPE, + text=True, + ) + self.assertEqual(completed.stdout, "shim-ok") + + def test_refuses_to_replace_existing_app_without_force(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + app_dir = root / "Codex Lab.app" + app_dir.mkdir() + + with self.assertRaises(FileExistsError): + build_codex_lab_app( + CodexLabAppOptions(app_dir=app_dir, codex_bin=Path("/bin/sh")) + ) + + def test_force_replaces_existing_shim(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + shim_dir = root / "bin" + shim_dir.mkdir() + os.symlink(Path("/bin/sh"), shim_dir / "codex-lab") + + result = build_codex_lab_app( + CodexLabAppOptions( + app_dir=root / "Codex Lab.app", + codex_bin=Path("/bin/sh"), + shim_dir=shim_dir, + force=True, + ) + ) + + self.assertIsNotNone(result.shim_path) + assert result.shim_path is not None + self.assertFalse(result.shim_path.is_symlink()) + + +if __name__ == "__main__": + unittest.main()